From b0d2b414a7789084c503beba6f162b3531923d15 Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Fri, 17 Apr 2026 15:01:18 +0100 Subject: [PATCH 01/89] [copilot] Created basic TopicDiscovery component with scrollable tabs and CurationGrid --- .../ScrollableTabs/index.styles.ts | 78 ++++ .../TopicDiscovery/ScrollableTabs/index.tsx | 157 ++++++++ src/app/components/TopicDiscovery/fixtures.ts | 378 ++++++++++++++++++ .../TopicDiscovery/index.stories.tsx | 30 ++ .../components/TopicDiscovery/index.styles.ts | 27 ++ src/app/components/TopicDiscovery/index.tsx | 86 ++++ src/app/components/TopicDiscovery/types.ts | 25 ++ 7 files changed, 781 insertions(+) create mode 100644 src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts create mode 100644 src/app/components/TopicDiscovery/ScrollableTabs/index.tsx create mode 100644 src/app/components/TopicDiscovery/fixtures.ts create mode 100644 src/app/components/TopicDiscovery/index.stories.tsx create mode 100644 src/app/components/TopicDiscovery/index.styles.ts create mode 100644 src/app/components/TopicDiscovery/index.tsx create mode 100644 src/app/components/TopicDiscovery/types.ts diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts new file mode 100644 index 00000000000..668ab24264d --- /dev/null +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -0,0 +1,78 @@ +import { css, Theme } from '@emotion/react'; + +const styles = { + wrapper: ({ palette, spacings }: Theme) => + css({ + display: 'flex', + alignItems: 'center', + gap: `${spacings.HALF}rem`, + borderBottom: `1px solid ${palette.GREY_5}`, + }), + + tabList: () => + css({ + display: 'flex', + overflowX: 'auto', + scrollBehavior: 'auto', + scrollbarWidth: 'none', + msOverflowStyle: 'none', + '&::-webkit-scrollbar': { + display: 'none', + }, + }), + + tab: ({ palette, spacings, fontSizes, fontVariants }: Theme) => + css({ + ...fontVariants.sansRegular, + ...fontSizes.pica, + whiteSpace: 'nowrap', + background: 'none', + border: 'none', + borderBottom: '3px solid transparent', + padding: `${spacings.FULL}rem ${spacings.DOUBLE}rem`, + cursor: 'pointer', + color: palette.GREY_6, + '&:hover': { + color: palette.GREY_10, + borderBottomColor: palette.GREY_5, + }, + '&:focus-visible': { + outline: `3px solid ${palette.BLACK}`, + outlineOffset: '-3px', + }, + }), + + tabActive: ({ palette }: Theme) => + css({ + color: palette.GREY_10, + borderBottomColor: palette.POSTBOX, + fontWeight: 700, + }), + + scrollButton: ({ palette, spacings }: Theme) => + css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + background: 'none', + border: 'none', + cursor: 'pointer', + padding: `${spacings.HALF}rem`, + color: palette.GREY_6, + '& svg': { + width: `${spacings.DOUBLE}rem`, + height: `${spacings.DOUBLE}rem`, + fill: 'currentcolor', + }, + '&:hover:not(:disabled)': { + color: palette.GREY_10, + }, + '&:disabled': { + cursor: 'default', + color: '#8A8C8E', + }, + }), +}; + +export default styles; diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx new file mode 100644 index 00000000000..d829a7c04fa --- /dev/null +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useRef, useState, use } from 'react'; +import { ServiceContext } from '#app/contexts/ServiceContext'; +import { Chevron, ChevronOrientation } from '#app/components/icons'; +import styles from './index.styles'; + +type ScrollableTabsProps = { + tabs: { id: string; label: string }[]; + activeTabId: string; + onTabChange: (tabId: string) => void; + labelledBy: string; +}; + +const ScrollableTabs = ({ + tabs, + activeTabId, + onTabChange, + labelledBy, +}: ScrollableTabsProps) => { + const { dir } = use(ServiceContext); + const tabListRef = useRef(null); + const [canScrollStart, setCanScrollStart] = useState(false); + const [canScrollEnd, setCanScrollEnd] = useState(false); + + const checkOverflow = useCallback(() => { + const el = tabListRef.current; + if (!el) return; + + const { scrollLeft, scrollWidth, clientWidth } = el; + const absScroll = Math.abs(scrollLeft); + + setCanScrollStart(absScroll > 0); + setCanScrollEnd(absScroll + clientWidth + 1 < scrollWidth); + }, []); + + useEffect(() => { + const el = tabListRef.current; + if (!el) return undefined; + + el.addEventListener('scroll', checkOverflow); + checkOverflow(); + + const resizeObserver = new ResizeObserver(checkOverflow); + resizeObserver.observe(el); + + return () => { + el.removeEventListener('scroll', checkOverflow); + resizeObserver.disconnect(); + }; + }, [checkOverflow]); + + const scroll = (direction: 'start' | 'end') => { + const el = tabListRef.current; + if (!el) return; + + const scrollAmount = el.clientWidth * 0.75; + const isForward = + (direction === 'end' && dir === 'ltr') || + (direction === 'start' && dir === 'rtl'); + + el.scrollBy({ + left: isForward ? scrollAmount : -scrollAmount, + behavior: 'smooth', + }); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + const currentIndex = tabs.findIndex(tab => tab.id === activeTabId); + let nextIndex: number | null = null; + + if (event.key === 'ArrowRight') { + nextIndex = + dir === 'ltr' + ? (currentIndex + 1) % tabs.length + : (currentIndex - 1 + tabs.length) % tabs.length; + } else if (event.key === 'ArrowLeft') { + nextIndex = + dir === 'ltr' + ? (currentIndex - 1 + tabs.length) % tabs.length + : (currentIndex + 1) % tabs.length; + } else if (event.key === 'Home') { + nextIndex = 0; + } else if (event.key === 'End') { + nextIndex = tabs.length - 1; + } + + if (nextIndex !== null) { + event.preventDefault(); + const nextTab = tabs[nextIndex]; + onTabChange(nextTab.id); + + const tabEl = tabListRef.current?.querySelector( + `[data-tab-id="${CSS.escape(nextTab.id)}"]`, + ) as HTMLButtonElement | null; + tabEl?.focus(); + tabEl?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + }; + + return ( +
+ + +
+ {tabs.map(tab => { + const isActive = tab.id === activeTabId; + return ( + + ); + })} +
+ + +
+ ); +}; + +export default ScrollableTabs; diff --git a/src/app/components/TopicDiscovery/fixtures.ts b/src/app/components/TopicDiscovery/fixtures.ts new file mode 100644 index 00000000000..095e66e9e38 --- /dev/null +++ b/src/app/components/TopicDiscovery/fixtures.ts @@ -0,0 +1,378 @@ +import { TopicDiscoveryData } from './types'; + +const topicDiscoveryFixture: TopicDiscoveryData = { + topics: [ + { + topicId: 'c340q43xynwt', + topicName: 'Comportamento', + topicUrl: 'https://www.bbc.com/portuguese/topics/c340q43xynwt', + items: [ + { + id: 'cy012wl72l5o', + type: 'audio', + title: + "Em áudio | As mulheres que se arrependem de serem mães: 'Uma armadilha impossível de escapar'", + link: 'https://www.bbc.com/portuguese/articles/cy012wl72l5o', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/b0d1/live/2687c2d0-397e-11f1-9d5c-8ba507d7dbde.jpg.webp', + imageAlt: 'Mãe com dois filhos junto ao mar', + firstPublished: '2026-04-16T10:23:41.003Z', + lastPublished: '2026-04-16T10:23:41.003Z', + isLive: false, + duration: 'PT11M53S', + description: + 'Lamentação pela vida que não têm mais e sensação de pressão constante estão entre razões apontadas por mulheres que disseram à BBC estarem arrependidas de se tornarem mães.', + isPortraitImage: false, + }, + { + id: 'cnv8evr62q9o', + type: 'article', + title: 'Por que a luz do celular não está prejudicando seu sono', + link: 'https://www.bbc.com/portuguese/articles/cnv8evr62q9o', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/de42/live/03300bf0-3404-11f1-8c66-61d0f886d14f.jpg.webp', + imageAlt: + 'Homem sorrindo com seu celular em frente a uma parede de tijolos pintada de azul', + firstPublished: '2026-04-13T15:08:54.675Z', + lastPublished: '2026-04-13T16:21:22.982Z', + isLive: false, + description: + 'Há mais de 10 anos, ouvimos que as nossas telas prejudicam o nosso sono. Mas a luz emitida pelo telefone celular está longe de ser a verdadeira razão que nos leva a dormir mal.', + isPortraitImage: false, + }, + { + id: 'c62k77z56y2o', + type: 'audio', + title: + "Em áudio | 'Jovens têm dificuldade de pegar o telefone e marcar uma consulta', diz pesquisadora americana", + link: 'https://www.bbc.com/portuguese/articles/c62k77z56y2o', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/b36b/live/3ea18120-3727-11f1-9d5c-8ba507d7dbde.jpg.webp', + imageAlt: + 'Maryellen MacDonald é uma mulher branca, de cabelos grisalhos e curtos.', + firstPublished: '2026-04-13T10:56:56.249Z', + lastPublished: '2026-04-13T10:56:56.249Z', + isLive: false, + duration: 'PT11M31S', + description: + 'Maryellen MacDonald alerta para consequências sociais, emocionais e até no ambiente de trabalho.', + isPortraitImage: false, + }, + { + id: 'cly09g341vzo', + type: 'article', + title: + 'As 10 melhores séries de TV de 2026 até agora, segundo críticos da BBC', + link: 'https://www.bbc.com/portuguese/articles/cly09g341vzo', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/6516/live/54473090-32bf-11f1-a79a-77e93010d956.jpg.webp', + imageAlt: 'Montagem com personagens de três séries de TV', + firstPublished: '2026-04-12T21:58:19.317Z', + lastPublished: '2026-04-12T21:58:19.317Z', + isLive: false, + description: + 'Entre dramas e comédias, os críticos da BBC indicam 10 das melhores séries de TV de 2026 até o momento.', + isPortraitImage: false, + }, + ], + }, + { + topicId: 'c340q4k1dq3t', + topicName: 'Mídia social', + topicUrl: 'https://www.bbc.com/portuguese/topics/c340q4k1dq3t', + items: [ + { + id: 'cd0rk8m531go', + type: 'video', + title: + 'Adolescente filma por acaso ataque aéreo em Beirute ao fazer selfie', + link: 'https://www.bbc.com/portuguese/articles/cd0rk8m531go', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/a5c4/live/2ffa1730-34da-11f1-9d5c-8ba507d7dbde.jpg.webp', + imageAlt: 'Menina no Líbano', + firstPublished: '2026-04-10T11:14:40.856Z', + lastPublished: '2026-04-10T11:14:40.856Z', + isLive: false, + duration: 'PT21S', + description: + 'Uma garota libanesa estava se filmando quando ataques aéreos israelenses atingiram Beirute.', + isPortraitImage: true, + }, + { + id: 'c895ypk110yo', + type: 'article', + title: + 'Vício em redes sociais: a reação da Meta e do Google à condenação', + link: 'https://www.bbc.com/portuguese/articles/c895ypk110yo', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/9939/live/8db26d80-2a05-11f1-af48-7dd725bb0b8b.jpg.webp', + imageAlt: + 'Mark Zuckerberg, vestindo um terno azul e gravata vermelha, caminhando pelo Capitólio.', + firstPublished: '2026-03-28T15:27:43.583Z', + lastPublished: '2026-03-28T15:27:43.583Z', + isLive: false, + description: + 'A decisão histórica de um tribunal de Los Angeles pode ter impactos que vão além dos imediatos sobre as empresas rés Meta e YouTube.', + isPortraitImage: false, + }, + { + id: 'cdxdn9j0rz9o', + type: 'article', + title: + 'Qual o impacto de condenação de Meta e Google nos EUA para o futuro das big techs?', + link: 'https://www.bbc.com/portuguese/articles/cdxdn9j0rz9o', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/7154/live/f23ab380-2928-11f1-a79a-77e93010d956.jpg.webp', + imageAlt: + 'Mark Zuckerberg, vestindo um terno azul-marinho e gravata cinza, ao sair do tribunal em Los Angeles', + firstPublished: '2026-03-26T15:26:18.639Z', + lastPublished: '2026-03-26T18:13:50.914Z', + isLive: false, + description: + 'Decisão que obrigou Meta e Google a indenizar jovem pode ser o começo do fim das redes sociais como as conhecemos.', + isPortraitImage: false, + }, + { + id: 'c9qd7dzqd8eo', + type: 'article', + title: + 'Por que julgamento sobre vício em redes sociais que condenou Meta e Google é histórico', + link: 'https://www.bbc.com/portuguese/articles/c9qd7dzqd8eo', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/a21d/live/fef5c380-1cdf-11f1-b4a6-b1a9fc5003ff.jpg.webp', + imageAlt: + 'Mark Zuckerberg cercado por três seguranças enquanto caminha em direção a um tribunal de Los Angeles', + firstPublished: '2026-03-25T18:41:39.941Z', + lastPublished: '2026-03-25T19:34:12.215Z', + isLive: false, + description: + 'Veredito marca o fim de um julgamento de cinco semanas sobre a natureza viciante das redes sociais.', + isPortraitImage: false, + }, + ], + }, + { + topicId: 'c95y3544238t', + topicName: 'Psicologia', + topicUrl: 'https://www.bbc.com/portuguese/topics/c95y3544238t', + items: [ + { + id: 'cyv1q6698rno', + type: 'article', + title: + 'Por que a popularidade de Freud e da psicanálise cresce em épocas de crise e autoritarismo', + link: 'https://www.bbc.com/portuguese/articles/cyv1q6698rno', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/4305/live/4c54b210-0e69-11f1-9f23-b3f349b149c7.jpg.webp', + imageAlt: + 'Sigmund Freud, um homem de expressão séria e olhar penetrante', + firstPublished: '2026-04-04T14:50:32.129Z', + lastPublished: '2026-04-04T14:50:32.129Z', + isLive: false, + description: + 'Em épocas de turbulência política, violência de Estado e trauma coletivo, a psicanálise oferece um caminho.', + isPortraitImage: false, + }, + { + id: 'cpv8eddj83po', + type: 'video', + title: + 'Millennials parecem mais novos que geração Z? O que diz a ciência', + link: 'https://www.bbc.com/portuguese/articles/cpv8eddj83po', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/7a8d/live/0fc97630-1ed3-11f1-b048-c9424b2cf5fd.jpg.webp', + imageAlt: 'Homem de bigode', + firstPublished: '2026-03-19T13:59:29.282Z', + lastPublished: '2026-03-19T13:59:29.282Z', + isLive: false, + duration: 'PT2M59S', + description: + 'Nos últimos tempos, uma trend nas redes sociais defende que as pessoas que pertencem à geracão millennial aparentam ser mais jovens.', + isPortraitImage: true, + }, + { + id: 'c1w5w8rzvrlo', + type: 'article', + title: 'Nove dicas sobre como lidar com a ansiedade', + link: 'https://www.bbc.com/portuguese/articles/c1w5w8rzvrlo', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/e6d3/live/82df8e30-22b5-11f1-b297-95b0a0a8331e.jpg.webp', + imageAlt: 'Duas cabras-montesas no paredão de uma montanha', + firstPublished: '2026-03-18T19:49:40.157Z', + lastPublished: '2026-03-18T19:49:40.157Z', + isLive: false, + description: + 'Em tempos de guerra, mudanças climáticas e instabilidades, a BBC reuniu nove sugestões para ajudar a manter a saúde mental.', + isPortraitImage: false, + }, + { + id: 'c4gjymdgzwpo', + type: 'audio', + title: + 'Em áudio | A morte do desejo sexual — e o grande debate sobre uso da testosterona', + link: 'https://www.bbc.com/portuguese/articles/c4gjymdgzwpo', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/1338/live/261c5e70-1b90-11f1-9120-a910fc22c6ac.jpg.webp', + imageAlt: 'Mulher e homem dormindo um de costas para o outro', + firstPublished: '2026-03-09T08:16:59.462Z', + lastPublished: '2026-03-09T08:16:59.462Z', + isLive: false, + duration: 'PT18M32S', + description: + 'Moda, busca abusiva por lucro ou impacto transformador? Especialistas dizem o que pensam.', + isPortraitImage: false, + }, + ], + }, + { + topicId: 'clmq8rgyyvjt', + topicName: 'Coronavírus', + topicUrl: 'https://www.bbc.com/portuguese/topics/clmq8rgyyvjt', + items: [ + { + id: 'c8xdy451lp2o', + type: 'article', + title: + 'Por que escolas cívico-militares têm fila de espera de 11 mil alunos no Paraná', + link: 'https://www.bbc.com/portuguese/articles/c8xdy451lp2o', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/7ee5/live/435c8250-efc4-11f0-b5f7-49f0357294ff.png.webp', + imageAlt: 'Estudantes em escola cívico-militar no Paraná', + firstPublished: '2026-01-15T09:17:38.434Z', + lastPublished: '2026-01-15T09:17:38.434Z', + isLive: false, + description: + 'Modelo rejeitado pelo governo federal vira destaque no Paraná.', + isPortraitImage: false, + }, + { + id: 'c39prj30w2go', + type: 'article', + title: + 'O estudo global que contesta queda da desigualdade no Brasil celebrada pelo governo Lula', + link: 'https://www.bbc.com/portuguese/articles/c39prj30w2go', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/d272/live/3f222670-d509-11f0-9fb5-5f3a3703a365.jpg.webp', + imageAlt: 'Favela entre prédios de elite no Rio de Janeiro', + firstPublished: '2025-12-10T08:25:29.584Z', + lastPublished: '2025-12-10T08:25:29.584Z', + isLive: false, + description: + 'Um novo relatório global indica que a concentração de renda subiu no Brasil entre 2014 e 2024.', + isPortraitImage: false, + }, + { + id: 'c0je47gvjn2o', + type: 'article', + title: + 'Como vulcão pode ter provocado pandemia que dizimou metade da população da Europa', + link: 'https://www.bbc.com/portuguese/articles/c0je47gvjn2o', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/7955/live/fe2739d0-d139-11f0-a892-01d657345866.jpg.webp', + imageAlt: 'Nuvem de fumaça e lava saindo da cratera de um vulcão', + firstPublished: '2025-12-05T17:30:43.265Z', + lastPublished: '2025-12-05T17:30:43.265Z', + isLive: false, + description: + 'Uma erupção vulcânica pode ter desencadeado uma reação em cadeia que levou à pandemia mais letal da Europa.', + isPortraitImage: false, + }, + { + id: 'c623440npdvo', + type: 'audio', + title: + "Em áudio | 'Aceitei um emprego por impulso num réveillon e acabei presa no mar por 6 meses'", + link: 'https://www.bbc.com/portuguese/articles/c623440npdvo', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/b40f/live/0e8bba80-c96a-11f0-8c06-f5d460985095.jpg.webp', + imageAlt: + 'Giulia Baccosi, de biquíni preto, sentada sobre a ponta da verga de um grande navio a vela', + firstPublished: '2025-11-24T19:18:43.571Z', + lastPublished: '2025-11-24T19:18:43.571Z', + isLive: false, + duration: 'PT10M59S', + description: + 'Giulia Baccosi aceitou trabalhar como cozinheira em navio que viajaria da Alemanha ao México.', + isPortraitImage: false, + }, + ], + }, + { + topicId: 'cz74k71p8ynt', + topicName: 'Sociedade', + topicUrl: 'https://www.bbc.com/portuguese/topics/cz74k71p8ynt', + items: [ + { + id: 'c9vl007r481o', + type: 'video', + title: + 'Dono de funerária é preso por enganar famílias e esconder corpos', + link: 'https://www.bbc.com/portuguese/articles/c9vl007r481o', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/8e09/live/9f1e98e0-372b-11f1-81c8-d992e62731d9.png.webp', + imageAlt: 'Bush', + firstPublished: '2026-04-15T09:10:41.080Z', + lastPublished: '2026-04-15T09:10:41.080Z', + isLive: false, + duration: 'PT2M59S', + description: + 'O dono de uma agência funerária na Inglaterra escondeu 35 corpos e entregou cinzas erradas.', + isPortraitImage: true, + }, + { + id: 'c5yxjj7g4e5o', + type: 'video', + title: + 'Homem é preso por ameaçar funcionário depois de receber pedido com molho errado', + link: 'https://www.bbc.com/portuguese/articles/c5yxjj7g4e5o', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/bdf6/live/894a8d60-3728-11f1-9d5c-8ba507d7dbde.jpg.webp', + imageAlt: 'homem ameaça funcionário com arma falsa', + firstPublished: '2026-04-14T13:01:20.774Z', + lastPublished: '2026-04-14T13:01:20.774Z', + isLive: false, + duration: 'PT44S', + description: + 'Um homem foi condenado a três anos de prisão após ameaçar atirar.', + isPortraitImage: true, + }, + { + id: 'cgld5pr11kwo', + type: 'video', + title: + "Avó que 'se preparava para morrer' ganha primeira viagem de avião da neta", + link: 'https://www.bbc.com/portuguese/articles/cgld5pr11kwo', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/4199/live/d60b6b60-3750-11f1-9d5c-8ba507d7dbde.jpg.webp', + imageAlt: 'Olha Pylypenko na praia', + firstPublished: '2026-04-14T08:21:00.965Z', + lastPublished: '2026-04-14T08:21:00.965Z', + isLive: false, + duration: 'PT1M12S', + description: + 'Quando Olha Pylypenko, de 85 anos, disse que estava se preparando para morrer, sua neta teve uma ideia.', + isPortraitImage: true, + }, + { + id: 'cx234k4jxlvo', + type: 'video', + title: + 'A ilha do Caribe onde os moradores não podem entrar nas praias', + link: 'https://www.bbc.com/portuguese/articles/cx234k4jxlvo', + imageUrl: + 'https://ichef.bbci.co.uk/ace/ws/{width}/cpsprodpb/0040/live/42370820-3720-11f1-872e-dbe4122cf753.jpg.webp', + imageAlt: 'praia na Jamaica', + firstPublished: '2026-04-13T16:23:40.574Z', + lastPublished: '2026-04-13T16:23:40.574Z', + isLive: false, + duration: 'PT2M54S', + description: + 'Ao longo das últimas sete décadas, as praias da Jamaica vêm sendo privatizadas.', + isPortraitImage: true, + }, + ], + }, + ], +}; + +export default topicDiscoveryFixture; diff --git a/src/app/components/TopicDiscovery/index.stories.tsx b/src/app/components/TopicDiscovery/index.stories.tsx new file mode 100644 index 00000000000..ad9cae14187 --- /dev/null +++ b/src/app/components/TopicDiscovery/index.stories.tsx @@ -0,0 +1,30 @@ +import TopicDiscovery from '.'; +import topicDiscoveryFixture from './fixtures'; + +const TopicDiscoveryStory = () => ( + +); + +export default { + title: 'Components/TopicDiscovery', + Component: TopicDiscoveryStory, +}; + +export const Default = TopicDiscoveryStory; + +export const SingleTopic = () => ( + +); + +export const NoData = () => ( + +); diff --git a/src/app/components/TopicDiscovery/index.styles.ts b/src/app/components/TopicDiscovery/index.styles.ts new file mode 100644 index 00000000000..3031e0c0781 --- /dev/null +++ b/src/app/components/TopicDiscovery/index.styles.ts @@ -0,0 +1,27 @@ +import { css, Theme } from '@emotion/react'; + +const styles = { + section: ({ spacings, mq }: Theme) => + css({ + marginTop: `${spacings.DOUBLE}rem`, + [mq.GROUP_4_MIN_WIDTH]: { + marginTop: `${spacings.TRIPLE}rem`, + }, + }), + + heading: ({ palette, spacings, fontSizes, fontVariants }: Theme) => + css({ + ...fontVariants.sansBold, + ...fontSizes.doublePica, + color: palette.GREY_10, + margin: 0, + paddingBottom: `${spacings.FULL}rem`, + }), + + tabPanel: ({ spacings }: Theme) => + css({ + paddingTop: `${spacings.DOUBLE}rem`, + }), +}; + +export default styles; diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx new file mode 100644 index 00000000000..9191afc73e1 --- /dev/null +++ b/src/app/components/TopicDiscovery/index.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import CurationGrid from '#app/components/Curation/CurationGrid'; +import { Summary } from '#app/models/types/curationData'; +import ScrollableTabs from './ScrollableTabs'; +import styles from './index.styles'; +import { TopicDiscoveryData, TopicDiscoveryItem } from './types'; + +type TopicDiscoveryProps = { + topicDiscovery: TopicDiscoveryData; + headingText: string; +}; + +const HEADING_ID = 'topic-discovery-heading'; + +const DEFAULT_IMAGE_WIDTH = 660; + +const eventTrackingData = { + componentName: 'topic-discovery', +}; + +const mapItemToSummary = (item: TopicDiscoveryItem): Summary => ({ + id: item.id, + title: item.title, + link: item.link, + imageUrl: item.imageUrl.replace('{width}', String(DEFAULT_IMAGE_WIDTH)), + imageAlt: item.imageAlt, + type: item.type, + mediaType: item.type === 'article' ? undefined : item.type, + description: item.description, + firstPublished: item.firstPublished, + lastPublished: item.lastPublished, + isLive: item.isLive, + duration: item.duration, + isPortraitImage: item.isPortraitImage, +}); + +const TopicDiscovery = ({ + topicDiscovery, + headingText, +}: TopicDiscoveryProps) => { + const validTopics = topicDiscovery.topics.filter( + topic => topic.items && topic.items.length > 0, + ); + + const [activeTabId, setActiveTabId] = useState(validTopics[0]?.topicId ?? ''); + + if (validTopics.length === 0) return null; + + const activeTopic = validTopics.find(topic => topic.topicId === activeTabId); + + if (!activeTopic) return null; + + const tabs = validTopics.map(topic => ({ + id: topic.topicId, + label: topic.topicName, + })); + + const summaries = activeTopic.items.map(mapItemToSummary); + + return ( +
+

+ {headingText} +

+ +
+ +
+
+ ); +}; + +export default TopicDiscovery; diff --git a/src/app/components/TopicDiscovery/types.ts b/src/app/components/TopicDiscovery/types.ts new file mode 100644 index 00000000000..ece8d219f9d --- /dev/null +++ b/src/app/components/TopicDiscovery/types.ts @@ -0,0 +1,25 @@ +export type TopicDiscoveryItem = { + id: string; + type: 'article' | 'audio' | 'video'; + title: string; + link: string; + imageUrl: string; + imageAlt: string; + firstPublished: string; + lastPublished: string; + isLive: boolean; + duration?: string; + description: string; + isPortraitImage: boolean; +}; + +export type TopicDiscoveryTopic = { + topicId: string; + topicName: string; + topicUrl: string; + items: TopicDiscoveryItem[]; +}; + +export type TopicDiscoveryData = { + topics: TopicDiscoveryTopic[]; +}; From d7dc56d138d231dac883f17098fed36a34ef2009 Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Fri, 17 Apr 2026 15:24:55 +0100 Subject: [PATCH 02/89] Added disabled attributes for buttonn and focus styling --- .../TopicDiscovery/ScrollableTabs/index.styles.ts | 4 ++++ src/app/components/TopicDiscovery/ScrollableTabs/index.tsx | 6 ++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index 668ab24264d..39fc265408f 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -72,6 +72,10 @@ const styles = { cursor: 'default', color: '#8A8C8E', }, + '&:focus-visible': { + outline: `3px solid ${palette.BLACK}`, + outlineOffset: '-3px', + }, }), }; diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx index d829a7c04fa..753038b5d90 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx @@ -103,8 +103,7 @@ const ScrollableTabs = ({ css={styles.scrollButton} onClick={() => scroll('start')} disabled={!canScrollStart} - aria-hidden="true" - tabIndex={-1} + aria-label="Scroll tabs left" data-testid="scroll-start" > @@ -144,8 +143,7 @@ const ScrollableTabs = ({ css={styles.scrollButton} onClick={() => scroll('end')} disabled={!canScrollEnd} - aria-hidden="true" - tabIndex={-1} + aria-label="Scroll tabs right" data-testid="scroll-end" > From 038e00c9ebdbec9e5eb4a578ad031c7b97275baa Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Fri, 17 Apr 2026 16:09:03 +0100 Subject: [PATCH 03/89] Updated ScrollableTabs tab padding and colours --- .../TopicDiscovery/ScrollableTabs/index.styles.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index 39fc265408f..f3235f98e9c 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -21,7 +21,7 @@ const styles = { }, }), - tab: ({ palette, spacings, fontSizes, fontVariants }: Theme) => + tab: ({ palette, spacings, fontSizes, fontVariants, mq }: Theme) => css({ ...fontVariants.sansRegular, ...fontSizes.pica, @@ -29,12 +29,15 @@ const styles = { background: 'none', border: 'none', borderBottom: '3px solid transparent', - padding: `${spacings.FULL}rem ${spacings.DOUBLE}rem`, + padding: `${spacings.DOUBLE}rem ${spacings.FULL}rem`, + [mq.GROUP_2_MIN_WIDTH]: { + padding: `${spacings.DOUBLE}rem`, + }, cursor: 'pointer', color: palette.GREY_6, '&:hover': { color: palette.GREY_10, - borderBottomColor: palette.GREY_5, + borderBottomColor: '#B80000', }, '&:focus-visible': { outline: `3px solid ${palette.BLACK}`, @@ -45,7 +48,7 @@ const styles = { tabActive: ({ palette }: Theme) => css({ color: palette.GREY_10, - borderBottomColor: palette.POSTBOX, + borderBottomColor: '#B80000', fontWeight: 700, }), From a61d00abba20be1512e5c10b5d9114ac15f38d5a Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Mon, 20 Apr 2026 10:50:28 +0100 Subject: [PATCH 04/89] Added hover styling --- .../components/TopicDiscovery/ScrollableTabs/index.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index f3235f98e9c..435656db04c 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -37,7 +37,7 @@ const styles = { color: palette.GREY_6, '&:hover': { color: palette.GREY_10, - borderBottomColor: '#B80000', + borderBottom: '4px solid #B80000', }, '&:focus-visible': { outline: `3px solid ${palette.BLACK}`, From 26e64ed34410bd2172f3e0699420858719be43b5 Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Mon, 20 Apr 2026 11:09:49 +0100 Subject: [PATCH 05/89] [copilot] Added gradient fade for left and right buttons --- .../ScrollableTabs/index.styles.ts | 52 ++++++++++++++++++- .../TopicDiscovery/ScrollableTabs/index.tsx | 46 +++++++++------- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index 435656db04c..7d85c70464c 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -57,11 +57,12 @@ const styles = { display: 'flex', alignItems: 'center', justifyContent: 'center', - flexShrink: 0, + width: '44px', + height: '44px', background: 'none', border: 'none', cursor: 'pointer', - padding: `${spacings.HALF}rem`, + padding: 0, color: palette.GREY_6, '& svg': { width: `${spacings.DOUBLE}rem`, @@ -80,6 +81,53 @@ const styles = { outlineOffset: '-3px', }, }), + + scrollButtonWrapper: () => + css({ + position: 'relative', + flexShrink: 0, + display: 'flex', + alignItems: 'center', + zIndex: 1, + }), + + scrollButtonFadeStart: () => + css({ + position: 'absolute', + top: 0, + height: '100%', + width: '16px', + pointerEvents: 'none', + "[dir='ltr'] &": { + right: '-16px', + background: + 'linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))', + }, + "[dir='rtl'] &": { + left: '-16px', + background: + 'linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))', + }, + }), + + scrollButtonFadeEnd: () => + css({ + position: 'absolute', + top: 0, + height: '100%', + width: '16px', + pointerEvents: 'none', + "[dir='ltr'] &": { + left: '-16px', + background: + 'linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))', + }, + "[dir='rtl'] &": { + right: '-16px', + background: + 'linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))', + }, + }), }; export default styles; diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx index 753038b5d90..93d1eca2c66 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx @@ -98,16 +98,19 @@ const ScrollableTabs = ({ return (
- +
+ +
- +
+
); }; From a0d2f4a00b10f2db41aaa5fb2ba484241994c40c Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Mon, 20 Apr 2026 11:57:09 +0100 Subject: [PATCH 06/89] Wired component to article page --- src/app/models/types/optimo.ts | 2 ++ src/app/models/types/translations.ts | 3 +++ src/app/pages/ArticlePage/ArticlePage.tsx | 9 +++++++++ .../pages/[service]/articles/handleArticleRoute.ts | 3 +++ 4 files changed, 17 insertions(+) diff --git a/src/app/models/types/optimo.ts b/src/app/models/types/optimo.ts index 3fb8028d8e0..0e057bb2129 100644 --- a/src/app/models/types/optimo.ts +++ b/src/app/models/types/optimo.ts @@ -4,6 +4,7 @@ import { MostReadData } from '#app/components/MostRead/types'; import { TopStoryItem } from '#app/pages/ArticlePage/PagePromoSections/TopStoriesSection/types'; import { LatestMedia } from '#app/pages/MediaArticlePage/PagePromoSections/LatestMediaSection/types'; import { PortraitClipMediaBlock } from '#app/components/MediaLoader/types'; +import { TopicDiscoveryData } from '#app/components/TopicDiscovery/types'; import { PageTypes } from './global'; import { MetadataFormats, MetadataTaggings, TopicTag } from './metadata'; import { Curation } from './curationData'; @@ -167,4 +168,5 @@ export type Article = { recommendations?: Recommendation[]; relatedContent?: RelatedContent; portraitVideoItems?: PortraitVideoItems; + topicDiscovery?: TopicDiscoveryData; }; diff --git a/src/app/models/types/translations.ts b/src/app/models/types/translations.ts index c4968590e16..0d77489c669 100644 --- a/src/app/models/types/translations.ts +++ b/src/app/models/types/translations.ts @@ -61,6 +61,9 @@ export interface Translations { 500: TranslationsError; }; continueReading?: string; + topicDiscovery?: { + heading: string; + }; readTime?: Partial<{ readTimePrefix: string; quick: string; diff --git a/src/app/pages/ArticlePage/ArticlePage.tsx b/src/app/pages/ArticlePage/ArticlePage.tsx index b2e8a9f5e94..c43af8080e6 100644 --- a/src/app/pages/ArticlePage/ArticlePage.tsx +++ b/src/app/pages/ArticlePage/ArticlePage.tsx @@ -81,6 +81,7 @@ import { } from '../../components/Byline/utilities'; import { ServiceContext } from '../../contexts/ServiceContext'; import RelatedContentSection from '../../components/RelatedContentSection'; +import TopicDiscovery from '../../components/TopicDiscovery'; import Disclaimer from '../../components/Disclaimer'; import SecondaryColumn from './SecondaryColumn'; import styles from './ArticlePage.styles'; @@ -512,6 +513,14 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { experimentProps: timeOfDayExperimentProps, })} /> + {!isAmp && !isLite && pageData.topicDiscovery && ( + + )}
{showAdaptiveMediaCuration && (
diff --git a/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts b/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts index 168ab7a8a81..10486f2ee6d 100644 --- a/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts +++ b/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts @@ -10,6 +10,7 @@ import handleError from '#app/routes/utils/handleError'; import { PageTypes } from '#app/models/types/global'; import { ArticleMetadata } from '#app/models/types/optimo'; +import topicDiscoveryFixture from '#app/components/TopicDiscovery/fixtures'; import augmentWithDisclaimer from './augmentWithDisclaimer'; import shouldRender from '../../../utilities/shouldRender'; import getPageData from '../../../utilities/pageRequests/getPageData'; @@ -111,6 +112,7 @@ export default async (context: GetServerSidePropsContext) => { billboardCuration = null, mediaCuration = null, portraitVideoItems = null, + topicDiscovery = null, } = secondaryData || {}; const transformedArticleData = transformPageData()(article); @@ -138,6 +140,7 @@ export default async (context: GetServerSidePropsContext) => { }, mostRead, portraitVideoItems, + topicDiscovery: topicDiscovery ?? topicDiscoveryFixture, }, pageType: derivedPageType, pathname: resolvedUrlWithoutQuery, From 2d67986b386872e2459cbf6e85d5a9b88e3d9c46 Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Mon, 20 Apr 2026 12:01:42 +0100 Subject: [PATCH 07/89] Wire TopicDiscovery component into ArticlePage with fixture data [copilot] --- src/app/lib/config/services/portuguese.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/lib/config/services/portuguese.ts b/src/app/lib/config/services/portuguese.ts index ded4da0cd4b..297e2061836 100644 --- a/src/app/lib/config/services/portuguese.ts +++ b/src/app/lib/config/services/portuguese.ts @@ -85,6 +85,9 @@ export const service: DefaultServiceConfig = { seeAll: 'Ver todos', home: 'Início', continueReading: 'Continue lendo', + topicDiscovery: { + heading: 'Tópicos relacionados', + }, currentPage: 'Página atual', skipLinkText: 'Vá para o conteúdo', relatedContent: 'Histórias relacionadas', From ac0bfb6c9f593d9bb5802cb1dc8dc7c918cb8507 Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Mon, 20 Apr 2026 14:58:24 +0100 Subject: [PATCH 08/89] Added view and click event tracking data [copilot] --- .../TopicDiscovery/ScrollableTabs/index.tsx | 10 +++++++++- src/app/components/TopicDiscovery/index.tsx | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx index 93d1eca2c66..cc03da68e1f 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx @@ -8,6 +8,10 @@ type ScrollableTabsProps = { activeTabId: string; onTabChange: (tabId: string) => void; labelledBy: string; + clickTrackerHandler?: { + onClick?: (event: React.MouseEvent) => void; + [key: string]: unknown; + }; }; const ScrollableTabs = ({ @@ -15,6 +19,7 @@ const ScrollableTabs = ({ activeTabId, onTabChange, labelledBy, + clickTrackerHandler, }: ScrollableTabsProps) => { const { dir } = use(ServiceContext); const tabListRef = useRef(null); @@ -133,7 +138,10 @@ const ScrollableTabs = ({ aria-controls={`tabpanel-${tab.id}`} tabIndex={isActive ? 0 : -1} css={[styles.tab, isActive && styles.tabActive]} - onClick={() => onTabChange(tab.id)} + onClick={event => { + onTabChange(tab.id); + clickTrackerHandler?.onClick?.(event); + }} > {tab.label} diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index 9191afc73e1..421afe11cc5 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; import CurationGrid from '#app/components/Curation/CurationGrid'; import { Summary } from '#app/models/types/curationData'; +import useViewTracker from '#app/hooks/useViewTracker'; +import useClickTrackerHandler from '#app/hooks/useClickTrackerHandler'; import ScrollableTabs from './ScrollableTabs'; import styles from './index.styles'; import { TopicDiscoveryData, TopicDiscoveryItem } from './types'; @@ -44,6 +46,12 @@ const TopicDiscovery = ({ const [activeTabId, setActiveTabId] = useState(validTopics[0]?.topicId ?? ''); + const viewTracker = useViewTracker(eventTrackingData); + const clickTrackerHandler = useClickTrackerHandler({ + ...eventTrackingData, + preventNavigation: true, + }); + if (validTopics.length === 0) return null; const activeTopic = validTopics.find(topic => topic.topicId === activeTabId); @@ -58,7 +66,11 @@ const TopicDiscovery = ({ const summaries = activeTopic.items.map(mapItemToSummary); return ( -
+

{headingText}

@@ -67,6 +79,7 @@ const TopicDiscovery = ({ activeTabId={activeTabId} onTabChange={setActiveTabId} labelledBy={HEADING_ID} + clickTrackerHandler={clickTrackerHandler} />
Date: Tue, 21 Apr 2026 15:13:07 +0100 Subject: [PATCH 09/89] Removed ResizeObserver and replaced with eventlisteners --- src/app/components/TopicDiscovery/ScrollableTabs/index.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx index cc03da68e1f..aea1cddf144 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx @@ -42,14 +42,12 @@ const ScrollableTabs = ({ if (!el) return undefined; el.addEventListener('scroll', checkOverflow); + window.addEventListener('resize', checkOverflow); checkOverflow(); - const resizeObserver = new ResizeObserver(checkOverflow); - resizeObserver.observe(el); - return () => { el.removeEventListener('scroll', checkOverflow); - resizeObserver.disconnect(); + window.removeEventListener('resize', checkOverflow); }; }, [checkOverflow]); From d0f3f31a2a2dc629fcf29b10fb225126a56874df Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Tue, 21 Apr 2026 15:14:35 +0100 Subject: [PATCH 10/89] [copilot] Added unit tests for topicDiscovery component --- .../components/TopicDiscovery/index.test.tsx | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/app/components/TopicDiscovery/index.test.tsx diff --git a/src/app/components/TopicDiscovery/index.test.tsx b/src/app/components/TopicDiscovery/index.test.tsx new file mode 100644 index 00000000000..7dcfd46aeb4 --- /dev/null +++ b/src/app/components/TopicDiscovery/index.test.tsx @@ -0,0 +1,184 @@ +import { + render, + screen, + fireEvent, +} from '#app/components/react-testing-library-with-providers'; +import * as viewTracking from '#app/hooks/useViewTracker'; +import * as clickTracking from '#app/hooks/useClickTrackerHandler'; +import topicDiscoveryFixture from './fixtures'; +import TopicDiscovery from '.'; + +describe('TopicDiscovery', () => { + it('should render the heading', () => { + render( + , + { service: 'portuguese' }, + ); + + expect( + screen.getByRole('heading', { name: 'Tópicos relacionados' }), + ).toBeInTheDocument(); + }); + + it('should render a section with the topic-discovery test id', () => { + render( + , + { service: 'portuguese' }, + ); + + expect(screen.getByTestId('topic-discovery')).toBeInTheDocument(); + }); + + it('should render tabs for each valid topic', () => { + render( + , + { service: 'portuguese' }, + ); + + expect( + screen.getByRole('tab', { name: 'Comportamento' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('tab', { name: 'Mídia social' }), + ).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Psicologia' })).toBeInTheDocument(); + }); + + it('should render the first topic as the active tab by default', () => { + render( + , + { service: 'portuguese' }, + ); + + expect(screen.getByRole('tab', { name: 'Comportamento' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('should render a tabpanel', () => { + render( + , + { service: 'portuguese' }, + ); + + expect(screen.getByRole('tabpanel')).toBeInTheDocument(); + }); + + it('should render promos for the active topic', () => { + render( + , + { service: 'portuguese' }, + ); + + const firstTopicTitle = topicDiscoveryFixture.topics[0].items[0].title; + expect(screen.getByText(firstTopicTitle)).toBeInTheDocument(); + }); + + it('should switch active topic when a different tab is clicked', () => { + render( + , + { service: 'portuguese' }, + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Mídia social' })); + + expect(screen.getByRole('tab', { name: 'Mídia social' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + + const secondTopicTitle = topicDiscoveryFixture.topics[1].items[0].title; + expect(screen.getByText(secondTopicTitle)).toBeInTheDocument(); + }); + + it('should not render when there are no valid topics', () => { + const { container } = render( + , + { service: 'portuguese' }, + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render when all topics have empty items', () => { + const { container } = render( + , + { service: 'portuguese' }, + ); + + expect(container).toBeEmptyDOMElement(); + }); + + describe('analytics', () => { + it('should call useViewTracker with topic-discovery component name', () => { + const viewTrackerSpy = jest.spyOn(viewTracking, 'default'); + + render( + , + { service: 'portuguese' }, + ); + + expect(viewTrackerSpy).toHaveBeenCalledWith({ + componentName: 'topic-discovery', + }); + + viewTrackerSpy.mockRestore(); + }); + + it('should call useClickTrackerHandler with topic-discovery component name and preventNavigation', () => { + const clickTrackerSpy = jest + .spyOn(clickTracking, 'default') + .mockImplementation(() => ({ onClick: jest.fn() })); + + render( + , + { service: 'portuguese' }, + ); + + expect(clickTrackerSpy).toHaveBeenCalledWith({ + componentName: 'topic-discovery', + preventNavigation: true, + }); + + clickTrackerSpy.mockRestore(); + }); + }); +}); From f113ba274d98377b9722b34a9a5ac0c1ad8f3b21 Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Wed, 22 Apr 2026 11:56:37 +0100 Subject: [PATCH 11/89] Added unit tests for scrollableTabs [copilot] --- .../ScrollableTabs/index.test.tsx | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx new file mode 100644 index 00000000000..b5875518d07 --- /dev/null +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx @@ -0,0 +1,219 @@ +import { + render, + screen, + fireEvent, +} from '#app/components/react-testing-library-with-providers'; +import ScrollableTabs from '.'; + +const mockTabs = [ + { id: 'tab-1', label: 'Comportamento' }, + { id: 'tab-2', label: 'Mídia social' }, + { id: 'tab-3', label: 'Psicologia' }, +]; + +describe('ScrollableTabs', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = jest.fn(); + }); + + it('should render all tabs', () => { + render( + , + ); + + expect( + screen.getByRole('tab', { name: 'Comportamento' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('tab', { name: 'Mídia social' }), + ).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Psicologia' })).toBeInTheDocument(); + }); + + it('should mark the active tab with aria-selected true', () => { + render( + , + ); + + expect(screen.getByRole('tab', { name: 'Mídia social' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByRole('tab', { name: 'Comportamento' })).toHaveAttribute( + 'aria-selected', + 'false', + ); + expect(screen.getByRole('tab', { name: 'Psicologia' })).toHaveAttribute( + 'aria-selected', + 'false', + ); + }); + + it('should set tabIndex 0 on the active tab and -1 on others', () => { + render( + , + ); + + expect(screen.getByRole('tab', { name: 'Comportamento' })).toHaveAttribute( + 'tabindex', + '0', + ); + expect(screen.getByRole('tab', { name: 'Mídia social' })).toHaveAttribute( + 'tabindex', + '-1', + ); + }); + + it('should call onTabChange when a tab is clicked', () => { + const onTabChange = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Mídia social' })); + expect(onTabChange).toHaveBeenCalledWith('tab-2'); + }); + + it('should call clickTrackerHandler onClick when a tab is clicked', () => { + const mockClickHandler = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Mídia social' })); + + expect(mockClickHandler).toHaveBeenCalledTimes(1); + }); + + it('should render a tablist with the correct aria-labelledby', () => { + render( + , + ); + + expect(screen.getByRole('tablist')).toHaveAttribute( + 'aria-labelledby', + 'my-heading', + ); + }); + + it('should render scroll buttons', () => { + render( + , + ); + + expect(screen.getByTestId('scroll-start')).toBeInTheDocument(); + expect(screen.getByTestId('scroll-end')).toBeInTheDocument(); + }); + + describe('keyboard navigation', () => { + it('should move to the next tab on ArrowRight in LTR', () => { + const onTabChange = jest.fn(); + + render( + , + ); + + fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' }); + + expect(onTabChange).toHaveBeenCalledWith('tab-2'); + }); + + it('should move to the previous tab on ArrowLeft in LTR', () => { + const onTabChange = jest.fn(); + + render( + , + ); + + fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowLeft' }); + + expect(onTabChange).toHaveBeenCalledWith('tab-1'); + }); + }); + + describe('RTL support', () => { + it('should move to the previous tab on ArrowRight in RTL', () => { + const onTabChange = jest.fn(); + + render( + , + { service: 'arabic' }, + ); + + fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' }); + + expect(onTabChange).toHaveBeenCalledWith('tab-1'); + }); + + it('should move to the next tab on ArrowLeft in RTL', () => { + const onTabChange = jest.fn(); + + render( + , + { service: 'arabic' }, + ); + + fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowLeft' }); + + expect(onTabChange).toHaveBeenCalledWith('tab-2'); + }); + }); +}); From ab81a57f093d0bec70d8e7b75499623d705f0e4b Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Wed, 22 Apr 2026 13:30:19 +0100 Subject: [PATCH 12/89] Made responsive to allow 2 promos before overflowing onto next line [copilot] --- .../components/TopicDiscovery/index.styles.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/app/components/TopicDiscovery/index.styles.ts b/src/app/components/TopicDiscovery/index.styles.ts index 3031e0c0781..72271c4fa04 100644 --- a/src/app/components/TopicDiscovery/index.styles.ts +++ b/src/app/components/TopicDiscovery/index.styles.ts @@ -18,9 +18,33 @@ const styles = { paddingBottom: `${spacings.FULL}rem`, }), - tabPanel: ({ spacings }: Theme) => + tabPanel: ({ spacings, mq }: Theme) => css({ paddingTop: `${spacings.DOUBLE}rem`, + + [mq.GROUP_2_MAX_WIDTH]: { + li: { + width: `calc(50% - ${spacings.FULL}rem)`, + marginInlineEnd: `${spacings.DOUBLE}rem`, + borderTop: 'none', + paddingTop: 0, + + '&:nth-of-type(2n)': { + marginInlineEnd: 0, + }, + + '.promo-image': { + width: '100%', + display: 'block', + }, + + '.promo-text': { + width: '100%', + display: 'block', + paddingInlineStart: 0, + }, + }, + }, }), }; From e7b34f3a81e975193bc3cc337738bde28092d2e9 Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Wed, 22 Apr 2026 14:19:45 +0100 Subject: [PATCH 13/89] Removed topic tags component as replaced by topic discovery --- src/app/pages/ArticlePage/ArticlePage.tsx | 26 +++-------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/src/app/pages/ArticlePage/ArticlePage.tsx b/src/app/pages/ArticlePage/ArticlePage.tsx index 1a4a3db1c8c..8d20bb251aa 100644 --- a/src/app/pages/ArticlePage/ArticlePage.tsx +++ b/src/app/pages/ArticlePage/ArticlePage.tsx @@ -34,7 +34,6 @@ import { getLang, } from '#lib/utilities/parseAssetData'; import filterForBlockType from '#lib/utilities/blockHandlers'; -import RelatedTopics from '#app/components/RelatedTopics'; import NielsenAnalytics from '#containers/NielsenAnalytics'; import InlinePodcastPromo from '#containers/PodcastPromo/Inline'; import { @@ -212,13 +211,8 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { const [showAllContent, setShowAllContent] = useState(false); const { isApp, isAmp, isLite, pageType } = use(RequestContext); - const { - articleAuthor, - isTrustProjectParticipant, - showRelatedTopics, - brandName, - translations, - } = use(ServiceContext); + const { articleAuthor, isTrustProjectParticipant, brandName, translations } = + use(ServiceContext); const { enabled: preloadLeadImageToggle } = useToggle('preloadLeadImage'); const { enabled: continueReadingButtonToggle } = useToggle( @@ -287,7 +281,6 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { const lastPublished = getLastPublished(pageData); const aboutTags = getAboutTags(pageData); const articleId = getArticleId(pageData) ?? ''; - const topics = pageData?.metadata?.topics ?? []; const blocks = pageData?.content?.model?.blocks ?? []; const mediaCurationContent = pageData?.secondaryColumn?.mediaCuration; const startsWithHeading = blocks?.[0]?.type === 'headline' || false; @@ -431,7 +424,6 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { const promoImage = promoImageRawBlock?.model?.locator; - const showTopics = Boolean(showRelatedTopics && topics.length > 0); const authors = bylineLinkedData?.map(data => data?.authorName).join(','); // show media curation only when the user is in adaptive variation const showAdaptiveMediaCuration = Boolean( @@ -505,18 +497,6 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { - {showTopics && ( - - )} {showPortraitVideoCarousel && ( { columnLayout="twoColumn" size="default" headingBackgroundColour={GREY_2} - mobileDivider={showTopics} + mobileDivider={!isAmp && !isLite && !!pageData.topicDiscovery} {...(timeOfDayExperimentProps && { experimentProps: timeOfDayExperimentProps, })} From de522379ce894f4c9bb1d7ae7bc8f2840a9a07d5 Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Thu, 23 Apr 2026 11:13:51 +0100 Subject: [PATCH 14/89] Added condition for topicTags to render test --- ws-nextjs-app/integration/common/topicTags.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ws-nextjs-app/integration/common/topicTags.ts b/ws-nextjs-app/integration/common/topicTags.ts index 626841e70d7..c53eafa9963 100644 --- a/ws-nextjs-app/integration/common/topicTags.ts +++ b/ws-nextjs-app/integration/common/topicTags.ts @@ -4,7 +4,12 @@ export default () => { const topicTags = document.querySelector( `aside[aria-labelledby*='related-topics'] a`, ); - expect(topicTags).toBeInTheDocument(); + + if (topicTags) { + expect(topicTags).toBeInTheDocument(); + } else { + expect(topicTags).toBeNull(); + } }); }); }; From aa4a4af85148d18216750a34ee92dafb20e3bd07 Mon Sep 17 00:00:00 2001 From: Nabeel Khan Date: Thu, 23 Apr 2026 14:57:04 +0100 Subject: [PATCH 15/89] Added aria-labelledby in an attempt to fix cypress test --- src/app/components/TopicDiscovery/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index 421afe11cc5..e976fa9b261 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -67,6 +67,7 @@ const TopicDiscovery = ({ return (
Date: Fri, 24 Apr 2026 12:14:00 +0100 Subject: [PATCH 16/89] Added documentation in readme --- src/app/components/TopicDiscovery/README.md | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/app/components/TopicDiscovery/README.md diff --git a/src/app/components/TopicDiscovery/README.md b/src/app/components/TopicDiscovery/README.md new file mode 100644 index 00000000000..72f9fe9c07a --- /dev/null +++ b/src/app/components/TopicDiscovery/README.md @@ -0,0 +1,71 @@ +# TopicDiscovery + +A tabbed content discovery component for article pages that surfaces closely related content based on topic-tag recommendations returned by the BFF (currently using fixture). +This component is intended to help readers continue exploring relevant content without manually searching, while enabling product teams to validate whether topic-based recommendations improve engagement and session depth. + +## Overview + +`TopicDiscovery` renders a topic-based recommendation module using BFF-provided data. Each topic is displayed as a tab, and selecting a tab reveals up to 4 associated content promos. + +The component is: + +- **Frontend-only** +- **Canonical-only** (not supported on AMP or lite) +- Designed for **experiment-based rollout** +- Instrumented for **analytics tracking** +- Built with **responsive** and **keyboard-accessible** behaviour in mind + +## Features + +- Renders only when valid topic discovery data is available +- Displays recommendations grouped into tabs by topic +- Supports: + - `article` + - `video` + - `audio` +- Shows the latest 4 items per topic (as provided by the BFF) +- Includes horizontally scrollable tabs with arrow controls +- Supports keyboard navigation across tabs +- Tracks: + - component impressions + - promo clicks + - tab interactions (via click handler where required) +- Reuses existing `CurationGrid` promo rendering patterns + +## Usage + +Minimal topicDiscovery shape: + +```json +{ + "topics": [ + { + "topicId": "env", + "topicName": "Environment", + "items": [ + { + "id": "item-1", + "title": "Climate action update", + "link": "/news/articles/item-1", + "imageUrl": "https://ichef.test.bbci.co.uk/images/ic/{width}xn/p01wjx8g.jpg", + "imageAlt": "Wind turbines", + "type": "article", + "description": "Short summary", + "firstPublished": 1600000000000 + } + ] + } + ] +} +``` + +Example usage: + +```tsx +import TopicDiscovery from '#app/components/TopicDiscovery'; + +; +``` From e8aeb9678eecff9919d8d074a8469182bc3ea4e9 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 09:54:59 +0100 Subject: [PATCH 17/89] Tidy up topicDiscovery and relatedTopics display logic --- src/app/pages/ArticlePage/ArticlePage.tsx | 116 ++++++++++++++-------- 1 file changed, 72 insertions(+), 44 deletions(-) diff --git a/src/app/pages/ArticlePage/ArticlePage.tsx b/src/app/pages/ArticlePage/ArticlePage.tsx index 8d20bb251aa..fa1614e06de 100644 --- a/src/app/pages/ArticlePage/ArticlePage.tsx +++ b/src/app/pages/ArticlePage/ArticlePage.tsx @@ -34,6 +34,7 @@ import { getLang, } from '#lib/utilities/parseAssetData'; import filterForBlockType from '#lib/utilities/blockHandlers'; +import RelatedTopics from '#app/components/RelatedTopics'; import NielsenAnalytics from '#containers/NielsenAnalytics'; import InlinePodcastPromo from '#containers/PodcastPromo/Inline'; import { @@ -211,8 +212,13 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { const [showAllContent, setShowAllContent] = useState(false); const { isApp, isAmp, isLite, pageType } = use(RequestContext); - const { articleAuthor, isTrustProjectParticipant, brandName, translations } = - use(ServiceContext); + const { + articleAuthor, + isTrustProjectParticipant, + showRelatedTopics, + brandName, + translations, + } = use(ServiceContext); const { enabled: preloadLeadImageToggle } = useToggle('preloadLeadImage'); const { enabled: continueReadingButtonToggle } = useToggle( @@ -281,9 +287,11 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { const lastPublished = getLastPublished(pageData); const aboutTags = getAboutTags(pageData); const articleId = getArticleId(pageData) ?? ''; + const topics = pageData?.metadata?.topics ?? []; const blocks = pageData?.content?.model?.blocks ?? []; const mediaCurationContent = pageData?.secondaryColumn?.mediaCuration; const startsWithHeading = blocks?.[0]?.type === 'headline' || false; + const topicDiscovery = pageData?.topicDiscovery; const bylineBlock = blocks.find( (block): block is OptimoBylineBlock => @@ -299,6 +307,8 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { const hasByline = bylineLinkedData.length > 0; + const authors = bylineLinkedData?.map(data => data?.authorName).join(','); + const articleAuthorTwitterHandle = hasByline ? getAuthorTwitterHandle(blocks) : null; @@ -358,6 +368,51 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { continueReadingButtonToggle, ); + const showRelatedTopicsComponent = Boolean( + showRelatedTopics && topics.length > 0, + ); + + // show media curation only when the user is in adaptive variation + const showAdaptiveMediaCuration = Boolean( + !isAmp && + !isLite && + !isApp && + !isPGL && + isAdaptiveTimeOfDayVariant && + mediaCurationContent?.summaries?.length, + ); + + // EXPERIMENT: PWA Promotional Banner + const shouldRenderPWAPromotionalBanner = + !isTopBarOJsEnabled || !pageData?.secondaryColumn?.topStories?.length; + + // EXPERIMENT: Topic Discovery + const showTopicDiscovery = Boolean(topicDiscovery && !isAmp && !isLite); + + const visuallyHiddenBlock = { + id: null, + model: { blocks: [singleTextBlock(headline)] }, + type: 'visuallyHiddenHeadline', + }; + + const articleBlocks = startsWithHeading + ? blocks + : [visuallyHiddenBlock, ...blocks]; + + const promoImageBlocks = + pageData?.promo?.images?.defaultPromoImage?.blocks ?? []; + + const promoImageAltTextBlock = filterForBlockType( + promoImageBlocks, + 'altText', + ); + + const promoImageRawBlock = filterForBlockType(promoImageBlocks, 'rawImage'); + const promoImageAltText = + promoImageAltTextBlock?.model?.blocks?.[0]?.model?.blocks?.[0]?.model?.text; + + const promoImage = promoImageRawBlock?.model?.locator; + const componentsToRender = { visuallyHiddenHeadline, headline: getHeadlineComponent, @@ -400,45 +455,6 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { }), }; - const visuallyHiddenBlock = { - id: null, - model: { blocks: [singleTextBlock(headline)] }, - type: 'visuallyHiddenHeadline', - }; - - const articleBlocks = startsWithHeading - ? blocks - : [visuallyHiddenBlock, ...blocks]; - - const promoImageBlocks = - pageData?.promo?.images?.defaultPromoImage?.blocks ?? []; - - const promoImageAltTextBlock = filterForBlockType( - promoImageBlocks, - 'altText', - ); - - const promoImageRawBlock = filterForBlockType(promoImageBlocks, 'rawImage'); - const promoImageAltText = - promoImageAltTextBlock?.model?.blocks?.[0]?.model?.blocks?.[0]?.model?.text; - - const promoImage = promoImageRawBlock?.model?.locator; - - const authors = bylineLinkedData?.map(data => data?.authorName).join(','); - // show media curation only when the user is in adaptive variation - const showAdaptiveMediaCuration = Boolean( - !isAmp && - !isLite && - !isApp && - !isPGL && - isAdaptiveTimeOfDayVariant && - mediaCurationContent?.summaries?.length, - ); - - // EXPERIMENT: PWA Promotional Banner - const shouldRenderPWAPromotionalBanner = - !isTopBarOJsEnabled || !pageData?.secondaryColumn?.topStories?.length; - return (
{/* EXPERIMENT: PWA Promotional Banner */} @@ -497,6 +513,18 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { + {showRelatedTopicsComponent && ( + + )} {showPortraitVideoCarousel && ( { experimentProps: timeOfDayExperimentProps, })} /> - {!isAmp && !isLite && pageData.topicDiscovery && ( + {showTopicDiscovery && ( { columnLayout="twoColumn" size="default" headingBackgroundColour={GREY_2} - mobileDivider={!isAmp && !isLite && !!pageData.topicDiscovery} + mobileDivider={showRelatedTopicsComponent} {...(timeOfDayExperimentProps && { experimentProps: timeOfDayExperimentProps, })} From 2685537ad87855024a8802eedb0d7d8209398178 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 09:56:03 +0100 Subject: [PATCH 18/89] Update ArticlePage.tsx --- src/app/pages/ArticlePage/ArticlePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/pages/ArticlePage/ArticlePage.tsx b/src/app/pages/ArticlePage/ArticlePage.tsx index fa1614e06de..5f39a7dfc5d 100644 --- a/src/app/pages/ArticlePage/ArticlePage.tsx +++ b/src/app/pages/ArticlePage/ArticlePage.tsx @@ -387,7 +387,7 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { !isTopBarOJsEnabled || !pageData?.secondaryColumn?.topStories?.length; // EXPERIMENT: Topic Discovery - const showTopicDiscovery = Boolean(topicDiscovery && !isAmp && !isLite); + const showTopicDiscovery = topicDiscovery && !isAmp && !isLite; const visuallyHiddenBlock = { id: null, From b89e80554cbabefb47aaa9071e26c7dea0968bb7 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 09:59:34 +0100 Subject: [PATCH 19/89] Prevent RelatedTopics showing if `topicDiscovery` data is found --- src/app/pages/ArticlePage/ArticlePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/pages/ArticlePage/ArticlePage.tsx b/src/app/pages/ArticlePage/ArticlePage.tsx index 5f39a7dfc5d..367f64f040e 100644 --- a/src/app/pages/ArticlePage/ArticlePage.tsx +++ b/src/app/pages/ArticlePage/ArticlePage.tsx @@ -369,7 +369,7 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => { ); const showRelatedTopicsComponent = Boolean( - showRelatedTopics && topics.length > 0, + showRelatedTopics && topics.length > 0 && !topicDiscovery, ); // show media curation only when the user is in adaptive variation From 5cf98a3ddbfcb6b5ecb4f16a3809e63c4715cd3d Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 10:11:13 +0100 Subject: [PATCH 20/89] Use `pixelToRem` util for pixel sizes --- .../ScrollableTabs/index.styles.ts | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index 7d85c70464c..35979d3b5e3 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -1,4 +1,5 @@ import { css, Theme } from '@emotion/react'; +import pixelsToRem from '#app/utilities/pixelsToRem'; const styles = { wrapper: ({ palette, spacings }: Theme) => @@ -6,7 +7,7 @@ const styles = { display: 'flex', alignItems: 'center', gap: `${spacings.HALF}rem`, - borderBottom: `1px solid ${palette.GREY_5}`, + borderBottom: `${pixelsToRem(1)}rem solid ${palette.GREY_5}`, }), tabList: () => @@ -28,7 +29,7 @@ const styles = { whiteSpace: 'nowrap', background: 'none', border: 'none', - borderBottom: '3px solid transparent', + borderBottom: `${pixelsToRem(3)}rem solid transparent`, padding: `${spacings.DOUBLE}rem ${spacings.FULL}rem`, [mq.GROUP_2_MIN_WIDTH]: { padding: `${spacings.DOUBLE}rem`, @@ -37,11 +38,11 @@ const styles = { color: palette.GREY_6, '&:hover': { color: palette.GREY_10, - borderBottom: '4px solid #B80000', + borderBottom: `${pixelsToRem(4)}rem solid #B80000`, }, '&:focus-visible': { - outline: `3px solid ${palette.BLACK}`, - outlineOffset: '-3px', + outline: `${pixelsToRem(3)}rem solid ${palette.BLACK}`, + outlineOffset: `${pixelsToRem(-3)}rem`, }, }), @@ -57,8 +58,8 @@ const styles = { display: 'flex', alignItems: 'center', justifyContent: 'center', - width: '44px', - height: '44px', + width: `${pixelsToRem(44)}rem`, + height: `${pixelsToRem(44)}rem`, background: 'none', border: 'none', cursor: 'pointer', @@ -77,8 +78,8 @@ const styles = { color: '#8A8C8E', }, '&:focus-visible': { - outline: `3px solid ${palette.BLACK}`, - outlineOffset: '-3px', + outline: `${pixelsToRem(3)}rem solid ${palette.BLACK}`, + outlineOffset: `${pixelsToRem(-3)}rem`, }, }), @@ -91,39 +92,39 @@ const styles = { zIndex: 1, }), - scrollButtonFadeStart: () => + scrollButtonFadeStart: ({ spacings }: Theme) => css({ position: 'absolute', top: 0, height: '100%', - width: '16px', + width: `${spacings.DOUBLE}rem`, pointerEvents: 'none', "[dir='ltr'] &": { - right: '-16px', + right: `-${spacings.DOUBLE}rem`, background: 'linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))', }, "[dir='rtl'] &": { - left: '-16px', + left: `-${spacings.DOUBLE}rem`, background: 'linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))', }, }), - scrollButtonFadeEnd: () => + scrollButtonFadeEnd: ({ spacings }: Theme) => css({ position: 'absolute', top: 0, height: '100%', - width: '16px', + width: `${spacings.DOUBLE}rem`, pointerEvents: 'none', "[dir='ltr'] &": { - left: '-16px', + left: `-${spacings.DOUBLE}rem`, background: 'linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))', }, "[dir='rtl'] &": { - right: '-16px', + right: `-${spacings.DOUBLE}rem`, background: 'linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))', }, From be42f6bebe4f0c48f0b9a807d1275abbd46061b9 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 10:13:03 +0100 Subject: [PATCH 21/89] Use `palette` colour --- .../components/TopicDiscovery/ScrollableTabs/index.styles.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index 35979d3b5e3..c9f42aa1867 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -38,7 +38,7 @@ const styles = { color: palette.GREY_6, '&:hover': { color: palette.GREY_10, - borderBottom: `${pixelsToRem(4)}rem solid #B80000`, + borderBottom: `${pixelsToRem(4)}rem solid ${palette.POSTBOX}`, }, '&:focus-visible': { outline: `${pixelsToRem(3)}rem solid ${palette.BLACK}`, @@ -49,7 +49,7 @@ const styles = { tabActive: ({ palette }: Theme) => css({ color: palette.GREY_10, - borderBottomColor: '#B80000', + borderBottomColor: palette.POSTBOX, fontWeight: 700, }), From 233fef3d06d9499cd84a01717e1785d436244408 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 10:13:35 +0100 Subject: [PATCH 22/89] Fix border bottom on hover --- .../components/TopicDiscovery/ScrollableTabs/index.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index c9f42aa1867..10ff294919f 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -38,7 +38,7 @@ const styles = { color: palette.GREY_6, '&:hover': { color: palette.GREY_10, - borderBottom: `${pixelsToRem(4)}rem solid ${palette.POSTBOX}`, + borderBottom: `${pixelsToRem(3)}rem solid ${palette.POSTBOX}`, }, '&:focus-visible': { outline: `${pixelsToRem(3)}rem solid ${palette.BLACK}`, From eb66fc8426227fcba8690e5ee6cd57ea37a6fb02 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 10:35:32 +0100 Subject: [PATCH 23/89] Remove fixture from Article gSSP --- ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts b/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts index 10486f2ee6d..4a49db3b7ac 100644 --- a/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts +++ b/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts @@ -10,7 +10,6 @@ import handleError from '#app/routes/utils/handleError'; import { PageTypes } from '#app/models/types/global'; import { ArticleMetadata } from '#app/models/types/optimo'; -import topicDiscoveryFixture from '#app/components/TopicDiscovery/fixtures'; import augmentWithDisclaimer from './augmentWithDisclaimer'; import shouldRender from '../../../utilities/shouldRender'; import getPageData from '../../../utilities/pageRequests/getPageData'; @@ -140,7 +139,7 @@ export default async (context: GetServerSidePropsContext) => { }, mostRead, portraitVideoItems, - topicDiscovery: topicDiscovery ?? topicDiscoveryFixture, + topicDiscovery, }, pageType: derivedPageType, pathname: resolvedUrlWithoutQuery, From f70e3be2322b7e00289fa45f9d18f387d0b512a0 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 10:39:06 +0100 Subject: [PATCH 24/89] Add Storybook story for a11y testing --- src/app/pages/ArticlePage/index.stories.tsx | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/app/pages/ArticlePage/index.stories.tsx b/src/app/pages/ArticlePage/index.stories.tsx index 37dedd7b80e..8d988c6a165 100644 --- a/src/app/pages/ArticlePage/index.stories.tsx +++ b/src/app/pages/ArticlePage/index.stories.tsx @@ -25,6 +25,7 @@ import { service as newsConfig } from '#app/lib/config/services/news'; import { Services } from '#app/models/types/global'; import { StoryArgs, StoryProps } from '#app/models/types/storybook'; import articleDataMultipleContributors from '#data/news/articles/cgrj2g29kzxo.json'; +import topicDiscoveryFixture from '#app/components/TopicDiscovery/fixtures'; import ArticlePageComponent from './ArticlePage'; const PageWithOptimizely = withOptimizelyProvider(ArticlePageComponent); @@ -275,6 +276,28 @@ export const ArticlePageWithMultipleContributors = { ), }; +export const ArticlePageWithTopicDiscovery = { + render: () => { + const articleDataWithTopicDiscovery = { + ...articleData, + data: { + ...articleData.data, + article: { + ...articleData.data.article, + topicDiscovery: topicDiscoveryFixture, + }, + }, + }; + + return ( + + ); + }, +}; + export const TestArticlePageWithLiteSiteLink = { render: () => ( Date: Mon, 27 Apr 2026 10:45:42 +0100 Subject: [PATCH 25/89] Use `palette` for gradient colouring --- .../ScrollableTabs/index.styles.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index 10ff294919f..fe5ea3b537f 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -92,7 +92,7 @@ const styles = { zIndex: 1, }), - scrollButtonFadeStart: ({ spacings }: Theme) => + scrollButtonFadeStart: ({ palette, spacings }: Theme) => css({ position: 'absolute', top: 0, @@ -101,17 +101,15 @@ const styles = { pointerEvents: 'none', "[dir='ltr'] &": { right: `-${spacings.DOUBLE}rem`, - background: - 'linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))', + background: `linear-gradient(to right, ${palette.GREY_2}, ${palette.GREY_2}00)`, }, "[dir='rtl'] &": { left: `-${spacings.DOUBLE}rem`, - background: - 'linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))', + background: `linear-gradient(to right, ${palette.GREY_2}00, ${palette.GREY_2})`, }, }), - scrollButtonFadeEnd: ({ spacings }: Theme) => + scrollButtonFadeEnd: ({ palette, spacings }: Theme) => css({ position: 'absolute', top: 0, @@ -120,13 +118,11 @@ const styles = { pointerEvents: 'none', "[dir='ltr'] &": { left: `-${spacings.DOUBLE}rem`, - background: - 'linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))', + background: `linear-gradient(to right, ${palette.GREY_2}00, ${palette.GREY_2})`, }, "[dir='rtl'] &": { right: `-${spacings.DOUBLE}rem`, - background: - 'linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))', + background: `linear-gradient(to right, ${palette.GREY_2}, ${palette.GREY_2}00)`, }, }), }; From 737aa630d96d2908c614a4d52e247eab74f5358b Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 10:51:45 +0100 Subject: [PATCH 26/89] Mobile spacing fixes --- src/app/components/TopicDiscovery/index.styles.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.styles.ts b/src/app/components/TopicDiscovery/index.styles.ts index 72271c4fa04..31ecc2907e6 100644 --- a/src/app/components/TopicDiscovery/index.styles.ts +++ b/src/app/components/TopicDiscovery/index.styles.ts @@ -3,9 +3,10 @@ import { css, Theme } from '@emotion/react'; const styles = { section: ({ spacings, mq }: Theme) => css({ - marginTop: `${spacings.DOUBLE}rem`, + padding: `0 ${spacings.DOUBLE}rem ${spacings.FULL}rem ${spacings.DOUBLE}rem`, + [mq.GROUP_4_MIN_WIDTH]: { - marginTop: `${spacings.TRIPLE}rem`, + padding: 0, }, }), @@ -15,7 +16,7 @@ const styles = { ...fontSizes.doublePica, color: palette.GREY_10, margin: 0, - paddingBottom: `${spacings.FULL}rem`, + paddingBottom: `${spacings.DOUBLE}rem`, }), tabPanel: ({ spacings, mq }: Theme) => From 37c3c3c9cabb161218df6ca4d257a056754e54db Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 10:55:51 +0100 Subject: [PATCH 27/89] Remove unneeded mapping --- src/app/components/TopicDiscovery/index.tsx | 25 ++------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index e976fa9b261..fb3e2f3a8ad 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -1,11 +1,10 @@ import { useState } from 'react'; import CurationGrid from '#app/components/Curation/CurationGrid'; -import { Summary } from '#app/models/types/curationData'; import useViewTracker from '#app/hooks/useViewTracker'; import useClickTrackerHandler from '#app/hooks/useClickTrackerHandler'; import ScrollableTabs from './ScrollableTabs'; import styles from './index.styles'; -import { TopicDiscoveryData, TopicDiscoveryItem } from './types'; +import { TopicDiscoveryData } from './types'; type TopicDiscoveryProps = { topicDiscovery: TopicDiscoveryData; @@ -14,28 +13,10 @@ type TopicDiscoveryProps = { const HEADING_ID = 'topic-discovery-heading'; -const DEFAULT_IMAGE_WIDTH = 660; - const eventTrackingData = { componentName: 'topic-discovery', }; -const mapItemToSummary = (item: TopicDiscoveryItem): Summary => ({ - id: item.id, - title: item.title, - link: item.link, - imageUrl: item.imageUrl.replace('{width}', String(DEFAULT_IMAGE_WIDTH)), - imageAlt: item.imageAlt, - type: item.type, - mediaType: item.type === 'article' ? undefined : item.type, - description: item.description, - firstPublished: item.firstPublished, - lastPublished: item.lastPublished, - isLive: item.isLive, - duration: item.duration, - isPortraitImage: item.isPortraitImage, -}); - const TopicDiscovery = ({ topicDiscovery, headingText, @@ -63,8 +44,6 @@ const TopicDiscovery = ({ label: topic.topicName, })); - const summaries = activeTopic.items.map(mapItemToSummary); - return (
From 8d946292f594ba85c32a335184669f2eaf91c645 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 11:17:09 +0100 Subject: [PATCH 28/89] Show/hide arrows based on container width --- .../ScrollableTabs/index.styles.ts | 5 +++++ .../TopicDiscovery/ScrollableTabs/index.tsx | 21 +++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index fe5ea3b537f..b6b6eca4c9c 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -92,6 +92,11 @@ const styles = { zIndex: 1, }), + scrollButtonWrapperHidden: () => + css({ + display: 'none', + }), + scrollButtonFadeStart: ({ palette, spacings }: Theme) => css({ position: 'absolute', diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx index aea1cddf144..7b61ab58f3c 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx @@ -23,6 +23,7 @@ const ScrollableTabs = ({ }: ScrollableTabsProps) => { const { dir } = use(ServiceContext); const tabListRef = useRef(null); + const [hasOverflow, setHasOverflow] = useState(false); const [canScrollStart, setCanScrollStart] = useState(false); const [canScrollEnd, setCanScrollEnd] = useState(false); @@ -32,9 +33,11 @@ const ScrollableTabs = ({ const { scrollLeft, scrollWidth, clientWidth } = el; const absScroll = Math.abs(scrollLeft); + const isOverflowing = scrollWidth > clientWidth + 1; - setCanScrollStart(absScroll > 0); - setCanScrollEnd(absScroll + clientWidth + 1 < scrollWidth); + setHasOverflow(isOverflowing); + setCanScrollStart(isOverflowing && absScroll > 0); + setCanScrollEnd(isOverflowing && absScroll + clientWidth + 1 < scrollWidth); }, []); useEffect(() => { @@ -101,7 +104,12 @@ const ScrollableTabs = ({ return (
-
+
); From b258381c1cfa1062244c3296380124a9188f0b91 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 11:23:45 +0100 Subject: [PATCH 30/89] Update tab font weight and colour --- .../TopicDiscovery/ScrollableTabs/index.styles.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index b6b6eca4c9c..4dc7495a9c0 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -24,7 +24,7 @@ const styles = { tab: ({ palette, spacings, fontSizes, fontVariants, mq }: Theme) => css({ - ...fontVariants.sansRegular, + ...fontVariants.sansBold, ...fontSizes.pica, whiteSpace: 'nowrap', background: 'none', @@ -35,9 +35,8 @@ const styles = { padding: `${spacings.DOUBLE}rem`, }, cursor: 'pointer', - color: palette.GREY_6, + color: palette.GREY_10, '&:hover': { - color: palette.GREY_10, borderBottom: `${pixelsToRem(3)}rem solid ${palette.POSTBOX}`, }, '&:focus-visible': { @@ -48,9 +47,7 @@ const styles = { tabActive: ({ palette }: Theme) => css({ - color: palette.GREY_10, borderBottomColor: palette.POSTBOX, - fontWeight: 700, }), scrollButton: ({ palette, spacings }: Theme) => From 2546115c56c051ea4e49092dfb42aa855c20ff6d Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 11:46:58 +0100 Subject: [PATCH 31/89] Use `::after` for setting active/hover underline --- .../ScrollableTabs/index.styles.ts | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index 4dc7495a9c0..bbe4d7bf9ee 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -22,22 +22,28 @@ const styles = { }, }), - tab: ({ palette, spacings, fontSizes, fontVariants, mq }: Theme) => + tab: ({ palette, spacings, fontSizes, fontVariants }: Theme) => css({ ...fontVariants.sansBold, ...fontSizes.pica, + position: 'relative', whiteSpace: 'nowrap', background: 'none', border: 'none', - borderBottom: `${pixelsToRem(3)}rem solid transparent`, - padding: `${spacings.DOUBLE}rem ${spacings.FULL}rem`, - [mq.GROUP_2_MIN_WIDTH]: { - padding: `${spacings.DOUBLE}rem`, - }, + padding: `${pixelsToRem(12)}rem ${spacings.FULL}rem`, cursor: 'pointer', color: palette.GREY_10, + '&:hover': { - borderBottom: `${pixelsToRem(3)}rem solid ${palette.POSTBOX}`, + '&::after': { + content: '""', + position: 'absolute', + left: 0, + bottom: 0, + width: '100%', + height: `${spacings.HALF}rem`, + background: palette.POSTBOX, + }, }, '&:focus-visible': { outline: `${pixelsToRem(3)}rem solid ${palette.BLACK}`, @@ -45,13 +51,22 @@ const styles = { }, }), - tabActive: ({ palette }: Theme) => + tabActive: ({ spacings, palette }: Theme) => css({ - borderBottomColor: palette.POSTBOX, + '&::after': { + content: '""', + position: 'absolute', + left: 0, + bottom: 0, + width: '100%', + height: `${spacings.HALF}rem`, + background: palette.POSTBOX, + }, }), scrollButton: ({ palette, spacings }: Theme) => css({ + position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -61,18 +76,15 @@ const styles = { border: 'none', cursor: 'pointer', padding: 0, - color: palette.GREY_6, + color: palette.GREY_10, '& svg': { width: `${spacings.DOUBLE}rem`, height: `${spacings.DOUBLE}rem`, fill: 'currentcolor', }, - '&:hover:not(:disabled)': { - color: palette.GREY_10, - }, '&:disabled': { cursor: 'default', - color: '#8A8C8E', + color: `${palette.GREY_5}`, }, '&:focus-visible': { outline: `${pixelsToRem(3)}rem solid ${palette.BLACK}`, @@ -86,7 +98,6 @@ const styles = { flexShrink: 0, display: 'flex', alignItems: 'center', - zIndex: 1, }), scrollButtonWrapperHidden: () => From 38ad7404b0087926fc6cb241ae888fdba44d4b52 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 12:34:08 +0100 Subject: [PATCH 32/89] Tab index and focus fixes --- .../ScrollableTabs/index.styles.ts | 8 ++-- .../TopicDiscovery/ScrollableTabs/index.tsx | 43 +------------------ 2 files changed, 5 insertions(+), 46 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index bbe4d7bf9ee..767fe728b0c 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -45,9 +45,9 @@ const styles = { background: palette.POSTBOX, }, }, - '&:focus-visible': { - outline: `${pixelsToRem(3)}rem solid ${palette.BLACK}`, + '[type=button]&:focus-visible': { outlineOffset: `${pixelsToRem(-3)}rem`, + boxShadow: 'none', }, }), @@ -86,9 +86,9 @@ const styles = { cursor: 'default', color: `${palette.GREY_5}`, }, - '&:focus-visible': { - outline: `${pixelsToRem(3)}rem solid ${palette.BLACK}`, + '[type=button]&:focus-visible': { outlineOffset: `${pixelsToRem(-3)}rem`, + boxShadow: 'none', }, }), diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx index 7b61ab58f3c..0f2a7cab5c9 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx @@ -69,39 +69,6 @@ const ScrollableTabs = ({ }); }; - const handleKeyDown = (event: React.KeyboardEvent) => { - const currentIndex = tabs.findIndex(tab => tab.id === activeTabId); - let nextIndex: number | null = null; - - if (event.key === 'ArrowRight') { - nextIndex = - dir === 'ltr' - ? (currentIndex + 1) % tabs.length - : (currentIndex - 1 + tabs.length) % tabs.length; - } else if (event.key === 'ArrowLeft') { - nextIndex = - dir === 'ltr' - ? (currentIndex - 1 + tabs.length) % tabs.length - : (currentIndex + 1) % tabs.length; - } else if (event.key === 'Home') { - nextIndex = 0; - } else if (event.key === 'End') { - nextIndex = tabs.length - 1; - } - - if (nextIndex !== null) { - event.preventDefault(); - const nextTab = tabs[nextIndex]; - onTabChange(nextTab.id); - - const tabEl = tabListRef.current?.querySelector( - `[data-tab-id="${CSS.escape(nextTab.id)}"]`, - ) as HTMLButtonElement | null; - tabEl?.focus(); - tabEl?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - } - }; - return (
-
+
{tabs.map(tab => { const isActive = tab.id === activeTabId; return ( @@ -142,7 +102,6 @@ const ScrollableTabs = ({ data-tab-id={tab.id} aria-selected={isActive} aria-controls={`tabpanel-${tab.id}`} - tabIndex={isActive ? 0 : -1} css={[styles.tab, isActive && styles.tabActive]} onClick={event => { onTabChange(tab.id); From c5c9f82ff18875c9cd4481f29dd201cc701a2ec4 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 12:39:03 +0100 Subject: [PATCH 33/89] Update index.styles.ts --- .../components/TopicDiscovery/ScrollableTabs/index.styles.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts index 767fe728b0c..5920be02ddc 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.styles.ts @@ -43,6 +43,7 @@ const styles = { width: '100%', height: `${spacings.HALF}rem`, background: palette.POSTBOX, + zIndex: 1, }, }, '[type=button]&:focus-visible': { @@ -61,6 +62,7 @@ const styles = { width: '100%', height: `${spacings.HALF}rem`, background: palette.POSTBOX, + zIndex: 1, }, }), From 59902a599b3da7c338fad4d6fe1651c77e16677e Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 14:05:18 +0100 Subject: [PATCH 34/89] Remove `tabIndex` test --- .../ScrollableTabs/index.test.tsx | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx index b5875518d07..787d4270e03 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx @@ -59,26 +59,6 @@ describe('ScrollableTabs', () => { ); }); - it('should set tabIndex 0 on the active tab and -1 on others', () => { - render( - , - ); - - expect(screen.getByRole('tab', { name: 'Comportamento' })).toHaveAttribute( - 'tabindex', - '0', - ); - expect(screen.getByRole('tab', { name: 'Mídia social' })).toHaveAttribute( - 'tabindex', - '-1', - ); - }); - it('should call onTabChange when a tab is clicked', () => { const onTabChange = jest.fn(); From e179633156bfbc2ea3c595d537d9b9d0955753b6 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 14:05:40 +0100 Subject: [PATCH 35/89] Re-add `role=tablist` --- src/app/components/TopicDiscovery/ScrollableTabs/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx index 0f2a7cab5c9..8780064dd88 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx @@ -90,7 +90,12 @@ const ScrollableTabs = ({
-
+
{tabs.map(tab => { const isActive = tab.id === activeTabId; return ( From 2bf6d1dc33e7dfc18a68845aa314c68e62984ef3 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 14:11:10 +0100 Subject: [PATCH 36/89] Remove keyboard nav tests --- .../ScrollableTabs/index.test.tsx | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx index 787d4270e03..89131a8df27 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx @@ -123,42 +123,6 @@ describe('ScrollableTabs', () => { expect(screen.getByTestId('scroll-end')).toBeInTheDocument(); }); - describe('keyboard navigation', () => { - it('should move to the next tab on ArrowRight in LTR', () => { - const onTabChange = jest.fn(); - - render( - , - ); - - fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' }); - - expect(onTabChange).toHaveBeenCalledWith('tab-2'); - }); - - it('should move to the previous tab on ArrowLeft in LTR', () => { - const onTabChange = jest.fn(); - - render( - , - ); - - fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowLeft' }); - - expect(onTabChange).toHaveBeenCalledWith('tab-1'); - }); - }); - describe('RTL support', () => { it('should move to the previous tab on ArrowRight in RTL', () => { const onTabChange = jest.fn(); From 52e4c43c9560c4c2832fac8db29ed84cf6abefe5 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 14:13:16 +0100 Subject: [PATCH 37/89] Add arrow button click to scroll test --- .../ScrollableTabs/index.test.tsx | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx index 89131a8df27..530eddfd981 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.test.tsx @@ -123,41 +123,51 @@ describe('ScrollableTabs', () => { expect(screen.getByTestId('scroll-end')).toBeInTheDocument(); }); - describe('RTL support', () => { - it('should move to the previous tab on ArrowRight in RTL', () => { - const onTabChange = jest.fn(); - - render( - , - { service: 'arabic' }, - ); - - fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' }); - - expect(onTabChange).toHaveBeenCalledWith('tab-1'); + it('should scroll the tab container when the chevrons are clicked', () => { + render( + , + ); + + let scrollLeft = 50; + const tabListElement = screen.getByRole('tablist'); + const scrollBy = jest.fn(({ left }) => { + scrollLeft += left; }); - it('should move to the next tab on ArrowLeft in RTL', () => { - const onTabChange = jest.fn(); + Object.defineProperty(tabListElement, 'clientWidth', { + configurable: true, + get: () => 200, + }); + Object.defineProperty(tabListElement, 'scrollWidth', { + configurable: true, + get: () => 500, + }); + Object.defineProperty(tabListElement, 'scrollLeft', { + configurable: true, + get: () => scrollLeft, + }); + Object.defineProperty(tabListElement, 'scrollBy', { + configurable: true, + value: scrollBy, + }); - render( - , - { service: 'arabic' }, - ); + fireEvent(window, new Event('resize')); - fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowLeft' }); + fireEvent.click(screen.getByTestId('scroll-end')); + fireEvent.click(screen.getByTestId('scroll-start')); - expect(onTabChange).toHaveBeenCalledWith('tab-2'); + expect(scrollBy).toHaveBeenNthCalledWith(1, { + left: 150, + behavior: 'smooth', + }); + expect(scrollBy).toHaveBeenNthCalledWith(2, { + left: -150, + behavior: 'smooth', }); }); }); From 72025cccbf445be875a6bce8afe1d30a06ffffe4 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 15:35:12 +0100 Subject: [PATCH 38/89] Improve sizing of media icon --- .../components/TopicDiscovery/index.styles.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/app/components/TopicDiscovery/index.styles.ts b/src/app/components/TopicDiscovery/index.styles.ts index 04902ada735..38ca1afdc81 100644 --- a/src/app/components/TopicDiscovery/index.styles.ts +++ b/src/app/components/TopicDiscovery/index.styles.ts @@ -23,6 +23,27 @@ const styles = { css({ paddingTop: `${spacings.DOUBLE}rem`, + li: { + '.promo-image': { + 'div div:last-child': { + div: { + padding: `${spacings.FULL}rem`, + position: 'absolute', + bottom: 0, + + svg: { + width: `${spacings.DOUBLE}rem`, + height: `${spacings.DOUBLE}rem`, + }, + + [mq.GROUP_2_MIN_WIDTH]: { + position: 'relative', + }, + }, + }, + }, + }, + [mq.GROUP_2_MAX_WIDTH]: { li: { width: `calc(50% - ${spacings.FULL}rem)`, From 80e22369fc6b25e35836294c78fc799a85b7636b Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 27 Apr 2026 15:42:26 +0100 Subject: [PATCH 39/89] Implement copilot suggestion for `aria-controls` --- src/app/components/TopicDiscovery/ScrollableTabs/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx index 8780064dd88..936083303f0 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx @@ -100,13 +100,13 @@ const ScrollableTabs = ({ const isActive = tab.id === activeTabId; return (
); diff --git a/src/app/lib/config/services/hausa.ts b/src/app/lib/config/services/hausa.ts index 841e89b9c01..0b1a7950b0a 100644 --- a/src/app/lib/config/services/hausa.ts +++ b/src/app/lib/config/services/hausa.ts @@ -82,6 +82,10 @@ export const service: DefaultServiceConfig = { seeAll: 'Duba su baki daya', home: 'Labaran Duniya', continueReading: 'Ci gaba da karantawa', + topicDiscovery: { + heading: 'Gano ƙarin abubuwa', + moreFrom: 'Ƙarin labarai daga', + }, currentPage: 'Shafin da ake ciki', skipLinkText: 'Tsallaka zuwa abubuwan da ke ciki', relatedContent: 'Karin bayani', diff --git a/src/app/lib/config/services/indonesia.ts b/src/app/lib/config/services/indonesia.ts index eba05ed269a..9b9b6791005 100644 --- a/src/app/lib/config/services/indonesia.ts +++ b/src/app/lib/config/services/indonesia.ts @@ -83,6 +83,10 @@ export const service: DefaultServiceConfig = { }, seeAll: 'Lihat semua', home: 'Berita', + topicDiscovery: { + heading: 'Temukan lebih banyak', + moreFrom: 'Selengkapnya dari', + }, currentPage: 'Halaman saat ini', skipLinkText: 'Langsung ke konten', relatedContent: 'Berita terkait', diff --git a/src/app/lib/config/services/marathi.ts b/src/app/lib/config/services/marathi.ts index c8db39c0fd5..1ebba898465 100644 --- a/src/app/lib/config/services/marathi.ts +++ b/src/app/lib/config/services/marathi.ts @@ -78,6 +78,11 @@ export const service: DefaultServiceConfig = { seeAll: 'सर्व पाहा', home: 'बातम्या', continueReading: 'पुढे वाचा', + topicDiscovery: { + heading: 'अधिक शोधा', + moreFrom: 'मधील अधिक', + topicTitleFirst: true, + }, currentPage: 'सध्याचे पान', skipLinkText: 'थेट मजकुरावर जा', relatedContent: 'संबंधित मजकूर', diff --git a/src/app/lib/config/services/portuguese.ts b/src/app/lib/config/services/portuguese.ts index 297e2061836..cc88512fb82 100644 --- a/src/app/lib/config/services/portuguese.ts +++ b/src/app/lib/config/services/portuguese.ts @@ -86,7 +86,8 @@ export const service: DefaultServiceConfig = { home: 'Início', continueReading: 'Continue lendo', topicDiscovery: { - heading: 'Tópicos relacionados', + heading: 'Descubra mais', + moreFrom: 'Mais de', }, currentPage: 'Página atual', skipLinkText: 'Vá para o conteúdo', diff --git a/src/app/lib/config/services/serbian.ts b/src/app/lib/config/services/serbian.ts index 4f9f551db03..ebe7bcef100 100644 --- a/src/app/lib/config/services/serbian.ts +++ b/src/app/lib/config/services/serbian.ts @@ -143,6 +143,10 @@ export const service: SerbianConfig = { }, seeAll: 'Pogledajte sve', home: 'Glavna stranica', + topicDiscovery: { + heading: 'Otkrijte više', + moreFrom: 'Više iz', + }, currentPage: 'Otvorena stranica', skipLinkText: 'Pređite na sadržaj', relatedContent: 'Povezano', @@ -534,6 +538,10 @@ export const service: SerbianConfig = { }, seeAll: 'Погледајте све', home: 'Главна страница', + topicDiscovery: { + heading: 'Откријте више', + moreFrom: 'Више из', + }, currentPage: 'Отворена страница', skipLinkText: 'Пређите на садржај', relatedContent: 'Повезано', diff --git a/src/app/lib/config/services/turkce.ts b/src/app/lib/config/services/turkce.ts index f07b976bb9a..1e9c8cf4be1 100644 --- a/src/app/lib/config/services/turkce.ts +++ b/src/app/lib/config/services/turkce.ts @@ -66,6 +66,11 @@ export const service: DefaultServiceConfig = { seeAll: 'Hepsini görüntüle', home: 'Ana sayfa', continueReading: 'Okumaya devam edin', + topicDiscovery: { + heading: 'Daha fazlasını keşfet', + moreFrom: 'hakkında daha fazla', + topicTitleFirst: true, + }, currentPage: 'Bulunduğunuz sayfa', skipLinkText: 'İçeriğe götür', relatedContent: 'İlgili haberler', diff --git a/src/app/models/types/translations.ts b/src/app/models/types/translations.ts index 77e40e22f00..4025116eba6 100644 --- a/src/app/models/types/translations.ts +++ b/src/app/models/types/translations.ts @@ -70,6 +70,8 @@ export interface Translations { continueReading?: string; topicDiscovery?: { heading: string; + moreFrom: string; + topicTitleFirst?: boolean; }; readTime?: Partial<{ readTimePrefix: string; From 2d4c94d85662e7ed10b9472641ddf417a479b80e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CLilyL0u=E2=80=9D?= Date: Fri, 1 May 2026 18:56:37 +0100 Subject: [PATCH 64/89] update new translation in unit test --- src/app/components/TopicDiscovery/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/TopicDiscovery/index.test.tsx b/src/app/components/TopicDiscovery/index.test.tsx index 04efa18e00e..a22ae84d36d 100644 --- a/src/app/components/TopicDiscovery/index.test.tsx +++ b/src/app/components/TopicDiscovery/index.test.tsx @@ -15,7 +15,7 @@ describe('TopicDiscovery', () => { }); expect( - screen.getByRole('heading', { name: 'Tópicos relacionados' }), + screen.getByRole('heading', { name: 'Descubra mais' }), ).toBeInTheDocument(); }); From 155dcab8fdab92fc421ff89b0a176494c2fc7cca Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Fri, 1 May 2026 19:47:42 +0100 Subject: [PATCH 65/89] Add fake loading and skeleton --- .../components/TopicDiscovery/index.styles.ts | 74 ++++++++++++++----- src/app/components/TopicDiscovery/index.tsx | 71 ++++++++++++++---- 2 files changed, 113 insertions(+), 32 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.styles.ts b/src/app/components/TopicDiscovery/index.styles.ts index 019db7d2763..540d9ce260e 100644 --- a/src/app/components/TopicDiscovery/index.styles.ts +++ b/src/app/components/TopicDiscovery/index.styles.ts @@ -1,3 +1,4 @@ +import pixelsToRem from '#app/utilities/pixelsToRem'; import { css, Theme } from '@emotion/react'; const styles = { @@ -25,7 +26,19 @@ const styles = { paddingTop: `${spacings.DOUBLE}rem`, li: { + width: `calc(50% - ${spacings.FULL}rem)`, + marginInlineEnd: `${spacings.DOUBLE}rem`, + borderTop: 'none', + paddingTop: 0, + + '&:nth-of-type(2n)': { + marginInlineEnd: 0, + }, + '.promo-image': { + width: '100%', + display: 'block', + 'div div:last-child': { div: { padding: `${spacings.FULL}rem`, @@ -43,32 +56,57 @@ const styles = { }, }, }, + + '.promo-text': { + width: '100%', + display: 'block', + paddingInlineStart: 0, + }, }, - [mq.GROUP_2_MAX_WIDTH]: { + [mq.GROUP_3_MIN_WIDTH]: { li: { - width: `calc(50% - ${spacings.FULL}rem)`, - marginInlineEnd: `${spacings.DOUBLE}rem`, - borderTop: 'none', - paddingTop: 0, + width: `calc(25% - 0.75rem)`, - '&:nth-of-type(2n)': { - marginInlineEnd: 0, - }, - - '.promo-image': { - width: '100%', - display: 'block', - }, - - '.promo-text': { - width: '100%', - display: 'block', - paddingInlineStart: 0, + '&:nth-of-type(2n):not(:last-of-type)': { + marginInlineEnd: `${spacings.DOUBLE}rem`, }, }, }, }), + skeletonGrid: ({ spacings, mq }: Theme) => + css({ + display: 'grid', + gap: `${spacings.DOUBLE}rem`, + gridTemplateColumns: '1fr 1fr', + + [mq.GROUP_3_MIN_WIDTH]: { + gridTemplateColumns: 'repeat(4, 1fr)', + }, + }), + skeletonCard: ({ spacings }: Theme) => + css({ + display: 'flex', + flexDirection: 'column', + gap: `${spacings.FULL}rem`, + }), + skeletonImage: ({ palette }: Theme) => + css({ + width: '100%', + aspectRatio: '16 / 9', + background: `linear-gradient(to right, ${palette.GREY_4} 0%, ${palette.GREY_3} 100%)`, + }), + skeletonTextLines: ({ spacings }: Theme) => + css({ + display: 'flex', + flexDirection: 'column', + gap: `${spacings.HALF}rem`, + }), + skeletonLine: ({ palette }: Theme) => + css({ + height: `${pixelsToRem(12)}rem`, + background: `linear-gradient(to right, ${palette.GREY_4} 0%, ${palette.GREY_3} 100%)`, + }), moreFromLink: ({ palette, spacings, fontSizes, fontVariants }: Theme) => css({ ...fontVariants.sansBold, diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index 59b134e1ed5..8a6a7aed1d5 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -6,6 +6,7 @@ import { ServiceContext } from '#app/contexts/ServiceContext'; import ScrollableTabs from './ScrollableTabs'; import styles from './index.styles'; import { multipleTopicsFixture } from './fixtures'; +import { TopicDiscoveryItem } from './types'; type TopicDiscoveryProps = { topics: Pick[]; @@ -17,11 +18,40 @@ const eventTrackingData = { componentName: 'topic-discovery', }; +const FAKE_FETCH_DELAY_MS = 600; + +const fetchTopicPromos = ( + topicId: TopicTag['topicId'], +): Promise => + new Promise(resolve => { + setTimeout(() => { + resolve(multipleTopicsFixture?.[topicId]?.data?.items || []); + }, FAKE_FETCH_DELAY_MS); + }); + const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { const { translations } = use(ServiceContext); - const [topicPromos, setTopicPromos] = useState([]); + const [topicPromos, setTopicPromos] = useState([]); + const [isLoading, setIsLoading] = useState(true); const [activeTabId, setActiveTabId] = useState(topics?.[0]?.topicId || ''); + useEffect(() => { + let isActive = true; + + setIsLoading(true); + + fetchTopicPromos(activeTabId).then(fetchedTopicPromos => { + if (!isActive) return; + + setTopicPromos(fetchedTopicPromos); + setIsLoading(false); + }); + + return () => { + isActive = false; + }; + }, [activeTabId]); + const viewTracker = useViewTracker(eventTrackingData); const activeTopic = topics?.find(topic => topic.topicId === activeTabId); @@ -31,11 +61,6 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { label: topic.topicName, })); - useEffect(() => { - // TODO: Replace with real data fetching logic when API is available - setTopicPromos(multipleTopicsFixture?.[activeTabId]?.data?.items || []); - }, [activeTabId]); - if (!topics || topics.length === 0) return null; return ( @@ -60,14 +85,32 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { aria-labelledby={`tab-${activeTabId}`} css={styles.tabPanel} > - - {`More from ${activeTopic?.topicName}`} + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+
+
+
+
+
+
+
+ ))} +
+ ) : ( + <> + + {`More from ${activeTopic?.topicName}`} + + )}
); From 1c781cf8e59ec4280aee2cb4d53c660ead580216 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Fri, 1 May 2026 19:53:10 +0100 Subject: [PATCH 66/89] Update index.tsx --- src/app/components/TopicDiscovery/index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index 8a6a7aed1d5..2e1b82056d5 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -61,6 +61,13 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { label: topic.topicName, })); + const handleTabChange = (nextTabId: TopicTag['topicId']) => { + if (nextTabId === activeTabId) return; + + setIsLoading(true); + setActiveTabId(nextTabId); + }; + if (!topics || topics.length === 0) return null; return ( @@ -76,7 +83,7 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => {
Date: Fri, 1 May 2026 19:57:37 +0100 Subject: [PATCH 67/89] Add 'More from' skeleton --- .../components/TopicDiscovery/index.styles.ts | 7 +++++ src/app/components/TopicDiscovery/index.tsx | 27 ++++++++++--------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.styles.ts b/src/app/components/TopicDiscovery/index.styles.ts index 540d9ce260e..5794e7832e6 100644 --- a/src/app/components/TopicDiscovery/index.styles.ts +++ b/src/app/components/TopicDiscovery/index.styles.ts @@ -107,6 +107,13 @@ const styles = { height: `${pixelsToRem(12)}rem`, background: `linear-gradient(to right, ${palette.GREY_4} 0%, ${palette.GREY_3} 100%)`, }), + skeletonMoreFromLink: ({ palette, spacings }: Theme) => + css({ + height: `${pixelsToRem(18)}rem`, + width: '40%', + marginTop: `${spacings.DOUBLE}rem`, + background: `linear-gradient(to right, ${palette.GREY_4} 0%, ${palette.GREY_3} 100%)`, + }), moreFromLink: ({ palette, spacings, fontSizes, fontVariants }: Theme) => css({ ...fontVariants.sansBold, diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index 2e1b82056d5..474c458d8a5 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -93,19 +93,22 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { css={styles.tabPanel} > {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( - // eslint-disable-next-line react/no-array-index-key -
-
-
-
-
-
+ <> +
+ {Array.from({ length: 4 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+
+
+
+
+
+
-
- ))} -
+ ))} +
+
+ ) : ( <> Date: Fri, 1 May 2026 22:42:27 +0100 Subject: [PATCH 68/89] tests fore more from Co-authored-by: Copilot --- .../components/TopicDiscovery/index.test.tsx | 51 +++++++++++++++++++ src/app/components/TopicDiscovery/index.tsx | 6 ++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/app/components/TopicDiscovery/index.test.tsx b/src/app/components/TopicDiscovery/index.test.tsx index a22ae84d36d..8309e9b015f 100644 --- a/src/app/components/TopicDiscovery/index.test.tsx +++ b/src/app/components/TopicDiscovery/index.test.tsx @@ -5,9 +5,18 @@ import { } from '#app/components/react-testing-library-with-providers'; import * as viewTracking from '#app/hooks/useViewTracker'; import * as clickTracking from '#app/hooks/useClickTrackerHandler'; +import { ServiceContext } from '#app/contexts/ServiceContext'; +import { ServiceConfig } from '#app/models/types/serviceConfig'; +import { service as portugueseConfig } from '#app/lib/config/services/portuguese'; +import { service as turkceConfig } from '#app/lib/config/services/turkce'; import { topicTagsFixture } from './fixtures'; import TopicDiscovery from '.'; +const topics = [ + { topicId: '1', topicName: 'Topic1', topicUrl: '/topics/climate' }, + { topicId: '2', topicName: 'Topic2', topicUrl: '/topics/economy' }, +]; + describe('TopicDiscovery', () => { it('should render the heading', () => { render(, { @@ -90,6 +99,48 @@ describe('TopicDiscovery', () => { expect(screen.getByText(secondTopicTitle)).toBeInTheDocument(); }); + it('renders the more from section with topic title last by default', () => { + const config = { ...portugueseConfig.default } as ServiceConfig; + render( + + + , + ); + // Topic name should be at the end in this language + const moreFrom = screen.getByTestId('topic-discovery-more-from'); + expect(moreFrom).toHaveTextContent('Mais de Topic1'); + }); + + it('renders the more from section with topic title first if topicTitleFirst is true', () => { + const config = { ...turkceConfig.default } as ServiceConfig; + render( + + + , + ); + // Topic name should be at the start in this language + const moreFrom = screen.getByTestId('topic-discovery-more-from'); + expect(moreFrom).toHaveTextContent('Topic1 hakkında daha fazla'); + }); + + it('renders the more from section with fallback if moreFrom is missing', () => { + // remove moreFrom from translations to test fallback + const portugueseTranslations = { + ...portugueseConfig.default.translations, + topicDiscovery: { heading: 'Discover more', topicTitleFirst: true }, + }; + const config = { + ...portugueseConfig.default, + translations: portugueseTranslations, + } as ServiceConfig; + render( + + + , + ); + expect(screen.getByText('More from Topic1')).toBeInTheDocument(); + }); + it('should not render when there are no valid topics', () => { const { container } = render(, { service: 'portuguese', diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index e563810af02..782a14dbe77 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -76,7 +76,11 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { summaries={topicPromos} eventTrackingData={eventTrackingData} /> - + {getMoreFromText()}
From e3626a241315ae2bc7fcc5d1d3309e75d6767b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CLilyL0u=E2=80=9D?= Date: Fri, 1 May 2026 23:14:29 +0100 Subject: [PATCH 69/89] test must now wait for component to load Co-authored-by: Copilot --- .../components/TopicDiscovery/index.test.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.test.tsx b/src/app/components/TopicDiscovery/index.test.tsx index 8309e9b015f..1d536739aa0 100644 --- a/src/app/components/TopicDiscovery/index.test.tsx +++ b/src/app/components/TopicDiscovery/index.test.tsx @@ -99,31 +99,31 @@ describe('TopicDiscovery', () => { expect(screen.getByText(secondTopicTitle)).toBeInTheDocument(); }); - it('renders the more from section with topic title last by default', () => { - const config = { ...portugueseConfig.default } as ServiceConfig; + it('renders the more from section with topic title last by default', async () => { + const config: ServiceConfig = { ...portugueseConfig.default }; render( , ); - // Topic name should be at the end in this language - const moreFrom = screen.getByTestId('topic-discovery-more-from'); + // Wait for loading to finish and the link to appear + const moreFrom = await screen.findByTestId('topic-discovery-more-from'); expect(moreFrom).toHaveTextContent('Mais de Topic1'); }); - it('renders the more from section with topic title first if topicTitleFirst is true', () => { - const config = { ...turkceConfig.default } as ServiceConfig; + it('renders the more from section with topic title first if topicTitleFirst is true', async () => { + const config: ServiceConfig = { ...turkceConfig.default }; render( , ); - // Topic name should be at the start in this language - const moreFrom = screen.getByTestId('topic-discovery-more-from'); + // Wait for loading to finish and the link to appear + const moreFrom = await screen.findByTestId('topic-discovery-more-from'); expect(moreFrom).toHaveTextContent('Topic1 hakkında daha fazla'); }); - it('renders the more from section with fallback if moreFrom is missing', () => { + it('renders the more from section with fallback if moreFrom is missing', async () => { // remove moreFrom from translations to test fallback const portugueseTranslations = { ...portugueseConfig.default.translations, @@ -138,7 +138,7 @@ describe('TopicDiscovery', () => { , ); - expect(screen.getByText('More from Topic1')).toBeInTheDocument(); + await screen.findByText('More from Topic1'); }); it('should not render when there are no valid topics', () => { From 248a77b104442c1e6077721c30554c7ab2bf8dc2 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Mon, 4 May 2026 18:47:11 +0100 Subject: [PATCH 70/89] Remove `topicDiscovery` from `article` type --- src/app/models/types/optimo.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/models/types/optimo.ts b/src/app/models/types/optimo.ts index f7349c6ced4..ef5cca71c1c 100644 --- a/src/app/models/types/optimo.ts +++ b/src/app/models/types/optimo.ts @@ -4,7 +4,6 @@ import { MostReadData } from '#app/components/MostRead/types'; import { TopStoryItem } from '#app/pages/ArticlePage/PagePromoSections/TopStoriesSection/types'; import { LatestMedia } from '#app/pages/MediaArticlePage/PagePromoSections/LatestMediaSection/types'; import { PortraitClipMediaBlock } from '#app/components/MediaLoader/types'; -import { TopicDiscoveryData } from '#app/components/TopicDiscovery/types'; import { PageTypes } from './global'; import { MetadataFormats, MetadataTaggings, TopicTag } from './metadata'; import { Curation } from './curationData'; @@ -171,5 +170,4 @@ export type Article = { recommendations?: Recommendation[]; relatedContent?: RelatedContent; portraitVideoItems?: PortraitVideoItems; - topicDiscovery?: TopicDiscoveryData; }; From 04f8f720ebfe45bbf489c25ed35ea8e9730e325d Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Tue, 5 May 2026 11:10:43 +0100 Subject: [PATCH 71/89] RTL fix for 'moreFromLink' skeleton --- src/app/components/TopicDiscovery/index.styles.ts | 5 +++++ src/app/components/TopicDiscovery/index.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/components/TopicDiscovery/index.styles.ts b/src/app/components/TopicDiscovery/index.styles.ts index 5794e7832e6..fddbaeb9eff 100644 --- a/src/app/components/TopicDiscovery/index.styles.ts +++ b/src/app/components/TopicDiscovery/index.styles.ts @@ -107,6 +107,11 @@ const styles = { height: `${pixelsToRem(12)}rem`, background: `linear-gradient(to right, ${palette.GREY_4} 0%, ${palette.GREY_3} 100%)`, }), + skeletonMoreFromLinkContainer: () => + css({ + display: 'flex', + alignItems: 'flex-start', + }), skeletonMoreFromLink: ({ palette, spacings }: Theme) => css({ height: `${pixelsToRem(18)}rem`, diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index 474c458d8a5..7e761ef4897 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -107,7 +107,9 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => {
))}
-
+
+
+
) : ( <> From 77e1379b03d0cb70afd8084236f03426fd7dca68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CLilyL0u=E2=80=9D?= Date: Tue, 5 May 2026 13:31:44 +0100 Subject: [PATCH 72/89] different way with {topic} Co-authored-by: Copilot --- src/app/components/TopicDiscovery/index.test.tsx | 6 +++--- src/app/components/TopicDiscovery/index.tsx | 13 +++++-------- src/app/lib/config/services/hausa.ts | 2 +- src/app/lib/config/services/indonesia.ts | 2 +- src/app/lib/config/services/marathi.ts | 3 +-- src/app/lib/config/services/portuguese.ts | 2 +- src/app/lib/config/services/serbian.ts | 4 ++-- src/app/lib/config/services/turkce.ts | 3 +-- src/app/models/types/translations.ts | 3 +-- 9 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.test.tsx b/src/app/components/TopicDiscovery/index.test.tsx index 1d536739aa0..3360b321749 100644 --- a/src/app/components/TopicDiscovery/index.test.tsx +++ b/src/app/components/TopicDiscovery/index.test.tsx @@ -99,7 +99,7 @@ describe('TopicDiscovery', () => { expect(screen.getByText(secondTopicTitle)).toBeInTheDocument(); }); - it('renders the more from section with topic title last by default', async () => { + it('renders the more from section with topic title last if {topic} is last in the config', async () => { const config: ServiceConfig = { ...portugueseConfig.default }; render( @@ -111,7 +111,7 @@ describe('TopicDiscovery', () => { expect(moreFrom).toHaveTextContent('Mais de Topic1'); }); - it('renders the more from section with topic title first if topicTitleFirst is true', async () => { + it('renders the more from section with topic title first if {topic} is first in the config', async () => { const config: ServiceConfig = { ...turkceConfig.default }; render( @@ -127,7 +127,7 @@ describe('TopicDiscovery', () => { // remove moreFrom from translations to test fallback const portugueseTranslations = { ...portugueseConfig.default.translations, - topicDiscovery: { heading: 'Discover more', topicTitleFirst: true }, + topicDiscovery: { heading: 'Discover more' }, }; const config = { ...portugueseConfig.default, diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index 91e847e90aa..8527e0a2550 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -69,15 +69,12 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { }; const getMoreFromText = () => { - const moreFrom = translations.topicDiscovery?.moreFrom; - const topicTitleFirst = translations.topicDiscovery?.topicTitleFirst; - if (!moreFrom) { - return `More from ${activeTopic?.topicName}`; + const moreFromTopic = translations.topicDiscovery?.moreFromTopic; + if (moreFromTopic && activeTopic?.topicName) { + return moreFromTopic.replace('{topic}', activeTopic.topicName); } - if (topicTitleFirst) { - return `${activeTopic?.topicName} ${moreFrom}`; - } - return `${moreFrom} ${activeTopic?.topicName}`; + // fallback to English if not present + return `More from ${activeTopic?.topicName}`; }; if (!topics || topics.length === 0) return null; diff --git a/src/app/lib/config/services/hausa.ts b/src/app/lib/config/services/hausa.ts index 0b1a7950b0a..7c6e9858c64 100644 --- a/src/app/lib/config/services/hausa.ts +++ b/src/app/lib/config/services/hausa.ts @@ -84,7 +84,7 @@ export const service: DefaultServiceConfig = { continueReading: 'Ci gaba da karantawa', topicDiscovery: { heading: 'Gano ƙarin abubuwa', - moreFrom: 'Ƙarin labarai daga', + moreFromTopic: 'Ƙarin labarai daga {topic}', }, currentPage: 'Shafin da ake ciki', skipLinkText: 'Tsallaka zuwa abubuwan da ke ciki', diff --git a/src/app/lib/config/services/indonesia.ts b/src/app/lib/config/services/indonesia.ts index 9b9b6791005..2a1d69c3966 100644 --- a/src/app/lib/config/services/indonesia.ts +++ b/src/app/lib/config/services/indonesia.ts @@ -85,7 +85,7 @@ export const service: DefaultServiceConfig = { home: 'Berita', topicDiscovery: { heading: 'Temukan lebih banyak', - moreFrom: 'Selengkapnya dari', + moreFromTopic: 'Selengkapnya dari {topic}', }, currentPage: 'Halaman saat ini', skipLinkText: 'Langsung ke konten', diff --git a/src/app/lib/config/services/marathi.ts b/src/app/lib/config/services/marathi.ts index 1ebba898465..7fceadf7e9e 100644 --- a/src/app/lib/config/services/marathi.ts +++ b/src/app/lib/config/services/marathi.ts @@ -80,8 +80,7 @@ export const service: DefaultServiceConfig = { continueReading: 'पुढे वाचा', topicDiscovery: { heading: 'अधिक शोधा', - moreFrom: 'मधील अधिक', - topicTitleFirst: true, + moreFromTopic: '{topic} मधील अधिक', }, currentPage: 'सध्याचे पान', skipLinkText: 'थेट मजकुरावर जा', diff --git a/src/app/lib/config/services/portuguese.ts b/src/app/lib/config/services/portuguese.ts index cc88512fb82..65a20f258b7 100644 --- a/src/app/lib/config/services/portuguese.ts +++ b/src/app/lib/config/services/portuguese.ts @@ -87,7 +87,7 @@ export const service: DefaultServiceConfig = { continueReading: 'Continue lendo', topicDiscovery: { heading: 'Descubra mais', - moreFrom: 'Mais de', + moreFromTopic: 'Mais de {topic}', }, currentPage: 'Página atual', skipLinkText: 'Vá para o conteúdo', diff --git a/src/app/lib/config/services/serbian.ts b/src/app/lib/config/services/serbian.ts index ebe7bcef100..78c26a10f8e 100644 --- a/src/app/lib/config/services/serbian.ts +++ b/src/app/lib/config/services/serbian.ts @@ -145,7 +145,7 @@ export const service: SerbianConfig = { home: 'Glavna stranica', topicDiscovery: { heading: 'Otkrijte više', - moreFrom: 'Više iz', + moreFromTopic: 'Više iz {topic}', }, currentPage: 'Otvorena stranica', skipLinkText: 'Pređite na sadržaj', @@ -540,7 +540,7 @@ export const service: SerbianConfig = { home: 'Главна страница', topicDiscovery: { heading: 'Откријте више', - moreFrom: 'Више из', + moreFromTopic: 'Више из {topic}', }, currentPage: 'Отворена страница', skipLinkText: 'Пређите на садржај', diff --git a/src/app/lib/config/services/turkce.ts b/src/app/lib/config/services/turkce.ts index 1e9c8cf4be1..cf56a9ab6ab 100644 --- a/src/app/lib/config/services/turkce.ts +++ b/src/app/lib/config/services/turkce.ts @@ -68,8 +68,7 @@ export const service: DefaultServiceConfig = { continueReading: 'Okumaya devam edin', topicDiscovery: { heading: 'Daha fazlasını keşfet', - moreFrom: 'hakkında daha fazla', - topicTitleFirst: true, + moreFromTopic: '{topic} hakkında daha fazla', }, currentPage: 'Bulunduğunuz sayfa', skipLinkText: 'İçeriğe götür', diff --git a/src/app/models/types/translations.ts b/src/app/models/types/translations.ts index 4025116eba6..427fa8e0ac9 100644 --- a/src/app/models/types/translations.ts +++ b/src/app/models/types/translations.ts @@ -70,8 +70,7 @@ export interface Translations { continueReading?: string; topicDiscovery?: { heading: string; - moreFrom: string; - topicTitleFirst?: boolean; + moreFromTopic: string; }; readTime?: Partial<{ readTimePrefix: string; From f0cb6e3388bce996d9c623bba724eaf116c6af52 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Tue, 5 May 2026 15:54:40 +0100 Subject: [PATCH 73/89] Add basic caching mechanism to not re-fetch promos --- src/app/components/TopicDiscovery/index.tsx | 27 +++++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index 7e761ef4897..b91befc1b33 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, use } from 'react'; +import { useState, useEffect, use, useRef } from 'react'; import CurationGrid from '#app/components/Curation/CurationGrid'; import useViewTracker from '#app/hooks/useViewTracker'; import { TopicTag } from '#app/models/types/metadata'; @@ -31,6 +31,7 @@ const fetchTopicPromos = ( const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { const { translations } = use(ServiceContext); + const promosCacheRef = useRef>({}); const [topicPromos, setTopicPromos] = useState([]); const [isLoading, setIsLoading] = useState(true); const [activeTabId, setActiveTabId] = useState(topics?.[0]?.topicId || ''); @@ -38,14 +39,22 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { useEffect(() => { let isActive = true; - setIsLoading(true); + const cachedPromos = promosCacheRef.current[activeTabId]; - fetchTopicPromos(activeTabId).then(fetchedTopicPromos => { - if (!isActive) return; - - setTopicPromos(fetchedTopicPromos); + if (cachedPromos) { + setTopicPromos(cachedPromos); setIsLoading(false); - }); + } else { + setIsLoading(true); + + fetchTopicPromos(activeTabId).then(fetchedTopicPromos => { + if (!isActive) return; + + promosCacheRef.current[activeTabId] = fetchedTopicPromos; + setTopicPromos(fetchedTopicPromos); + setIsLoading(false); + }); + } return () => { isActive = false; @@ -64,7 +73,9 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { const handleTabChange = (nextTabId: TopicTag['topicId']) => { if (nextTabId === activeTabId) return; - setIsLoading(true); + const hasCachedPromos = Boolean(promosCacheRef.current[nextTabId]); + + setIsLoading(!hasCachedPromos); setActiveTabId(nextTabId); }; From bcdf6788b5a217f8ec429e3e56a088f5be34a750 Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Tue, 5 May 2026 16:06:44 +0100 Subject: [PATCH 74/89] Move Topic Discovery as first OJ --- src/app/components/TopicDiscovery/index.styles.ts | 1 - src/app/pages/ArticlePage/ArticlePage.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.styles.ts b/src/app/components/TopicDiscovery/index.styles.ts index fddbaeb9eff..3ce41a97f27 100644 --- a/src/app/components/TopicDiscovery/index.styles.ts +++ b/src/app/components/TopicDiscovery/index.styles.ts @@ -7,7 +7,6 @@ const styles = { padding: `0 ${spacings.DOUBLE}rem ${spacings.FULL}rem ${spacings.DOUBLE}rem`, [mq.GROUP_4_MIN_WIDTH]: { - marginTop: `${spacings.TRIPLE}rem`, padding: 0, }, }), diff --git a/src/app/pages/ArticlePage/ArticlePage.tsx b/src/app/pages/ArticlePage/ArticlePage.tsx index 18de4a6d3b6..40e9709379c 100644 --- a/src/app/pages/ArticlePage/ArticlePage.tsx +++ b/src/app/pages/ArticlePage/ArticlePage.tsx @@ -518,6 +518,7 @@ const ArticlePage = ({ + {showTopicDiscovery && } {showRelatedTopicsComponent && ( - {showTopicDiscovery && }
{showAdaptiveMediaCuration && (
From be821bb770bb2bc9ef2b793eec5c2fcc00f3a21b Mon Sep 17 00:00:00 2001 From: Aaron Moore Date: Tue, 5 May 2026 16:10:23 +0100 Subject: [PATCH 75/89] Add test for caching behaviour --- .../components/TopicDiscovery/index.test.tsx | 45 ++++++++++++++++++- src/app/components/TopicDiscovery/index.tsx | 2 +- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.test.tsx b/src/app/components/TopicDiscovery/index.test.tsx index 04efa18e00e..669f19a810c 100644 --- a/src/app/components/TopicDiscovery/index.test.tsx +++ b/src/app/components/TopicDiscovery/index.test.tsx @@ -2,11 +2,12 @@ import { render, screen, fireEvent, + act, } from '#app/components/react-testing-library-with-providers'; import * as viewTracking from '#app/hooks/useViewTracker'; import * as clickTracking from '#app/hooks/useClickTrackerHandler'; import { topicTagsFixture } from './fixtures'; -import TopicDiscovery from '.'; +import TopicDiscovery, { FAKE_FETCH_DELAY_MS } from '.'; describe('TopicDiscovery', () => { it('should render the heading', () => { @@ -90,6 +91,48 @@ describe('TopicDiscovery', () => { expect(screen.getByText(secondTopicTitle)).toBeInTheDocument(); }); + it('should use cached promos when switching back to previously visited tabs', async () => { + jest.useFakeTimers(); + + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const getFetchTimeoutCallCount = () => + setTimeoutSpy.mock.calls.filter( + ([, delay]) => delay === FAKE_FETCH_DELAY_MS, + ).length; + + render(, { + service: 'portuguese', + }); + + expect(getFetchTimeoutCallCount()).toBe(1); + + await act(async () => { + jest.advanceTimersByTime(FAKE_FETCH_DELAY_MS); + }); + + fireEvent.click( + screen.getByRole('tab', { name: topicTagsFixture[1].topicName }), + ); + + expect(getFetchTimeoutCallCount()).toBe(2); + + await act(async () => { + jest.advanceTimersByTime(FAKE_FETCH_DELAY_MS); + }); + + fireEvent.click( + screen.getByRole('tab', { name: topicTagsFixture[0].topicName }), + ); + + expect(getFetchTimeoutCallCount()).toBe(2); + + fireEvent.click( + screen.getByRole('tab', { name: topicTagsFixture[1].topicName }), + ); + + expect(getFetchTimeoutCallCount()).toBe(2); + }); + it('should not render when there are no valid topics', () => { const { container } = render(, { service: 'portuguese', diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index b91befc1b33..0d8697334de 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -18,7 +18,7 @@ const eventTrackingData = { componentName: 'topic-discovery', }; -const FAKE_FETCH_DELAY_MS = 600; +export const FAKE_FETCH_DELAY_MS = 600; const fetchTopicPromos = ( topicId: TopicTag['topicId'], From 70bb0adff2c0e4ac1533d14acd73af00191ef696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CLilyL0u=E2=80=9D?= Date: Tue, 5 May 2026 16:34:15 +0100 Subject: [PATCH 76/89] merge with latest canges added unit tests --- src/app/components/TopicDiscovery/index.test.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.test.tsx b/src/app/components/TopicDiscovery/index.test.tsx index 5815c4b2f36..00bd336bc34 100644 --- a/src/app/components/TopicDiscovery/index.test.tsx +++ b/src/app/components/TopicDiscovery/index.test.tsx @@ -100,7 +100,6 @@ describe('TopicDiscovery', () => { expect(screen.getByText(secondTopicTitle)).toBeInTheDocument(); }); -<<<<<<< ws-2559-support-translations-for-topic-discovery-experiment it('renders the more from section with topic title last if {topic} is last in the config', async () => { const config: ServiceConfig = { ...portugueseConfig.default }; render( @@ -141,7 +140,8 @@ describe('TopicDiscovery', () => { , ); await screen.findByText('More from Topic1'); -======= + }); + it('should use cached promos when switching back to previously visited tabs', async () => { jest.useFakeTimers(); @@ -182,7 +182,6 @@ describe('TopicDiscovery', () => { ); expect(getFetchTimeoutCallCount()).toBe(2); ->>>>>>> WS-2397-front-end-build-for-new-topic-discovery-component }); it('should not render when there are no valid topics', () => { From 4b3cd4266ab3d9b22f8b9942fe491d145f62a4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CLilyL0u=E2=80=9D?= Date: Tue, 5 May 2026 16:35:09 +0100 Subject: [PATCH 77/89] quotes in test name for clarity --- src/app/components/TopicDiscovery/index.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.test.tsx b/src/app/components/TopicDiscovery/index.test.tsx index 00bd336bc34..43130817930 100644 --- a/src/app/components/TopicDiscovery/index.test.tsx +++ b/src/app/components/TopicDiscovery/index.test.tsx @@ -100,7 +100,7 @@ describe('TopicDiscovery', () => { expect(screen.getByText(secondTopicTitle)).toBeInTheDocument(); }); - it('renders the more from section with topic title last if {topic} is last in the config', async () => { + it('renders the "more from" section with topic title last if {topic} is last in the config', async () => { const config: ServiceConfig = { ...portugueseConfig.default }; render( @@ -112,7 +112,7 @@ describe('TopicDiscovery', () => { expect(moreFrom).toHaveTextContent('Mais de Topic1'); }); - it('renders the more from section with topic title first if {topic} is first in the config', async () => { + it('renders the "more from" section with topic title first if {topic} is first in the config', async () => { const config: ServiceConfig = { ...turkceConfig.default }; render( @@ -124,7 +124,7 @@ describe('TopicDiscovery', () => { expect(moreFrom).toHaveTextContent('Topic1 hakkında daha fazla'); }); - it('renders the more from section with fallback if moreFrom is missing', async () => { + it('renders the "more from" section with fallback if moreFrom is missing', async () => { // remove moreFrom from translations to test fallback const portugueseTranslations = { ...portugueseConfig.default.translations, From 637af470105ea8650ceb3ce5711e74c42d064fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CLilyL0u=E2=80=9D?= Date: Tue, 5 May 2026 16:37:07 +0100 Subject: [PATCH 78/89] removed comments --- src/app/components/TopicDiscovery/index.test.tsx | 1 - src/app/components/TopicDiscovery/index.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.test.tsx b/src/app/components/TopicDiscovery/index.test.tsx index 43130817930..c053327c036 100644 --- a/src/app/components/TopicDiscovery/index.test.tsx +++ b/src/app/components/TopicDiscovery/index.test.tsx @@ -125,7 +125,6 @@ describe('TopicDiscovery', () => { }); it('renders the "more from" section with fallback if moreFrom is missing', async () => { - // remove moreFrom from translations to test fallback const portugueseTranslations = { ...portugueseConfig.default.translations, topicDiscovery: { heading: 'Discover more' }, diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index a82fc8c8963..783a19fac8f 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -84,7 +84,6 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { if (moreFromTopic && activeTopic?.topicName) { return moreFromTopic.replace('{topic}', activeTopic.topicName); } - // fallback to English if not present return `More from ${activeTopic?.topicName}`; }; From f6a38d5116149ea2394314979aeefa7ed61d7f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CLilyL0u=E2=80=9D?= Date: Tue, 5 May 2026 16:40:29 +0100 Subject: [PATCH 79/89] destructure translations first Co-authored-by: Copilot --- src/app/components/TopicDiscovery/index.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/components/TopicDiscovery/index.tsx b/src/app/components/TopicDiscovery/index.tsx index 783a19fac8f..e16dd3c0aba 100644 --- a/src/app/components/TopicDiscovery/index.tsx +++ b/src/app/components/TopicDiscovery/index.tsx @@ -31,6 +31,7 @@ const fetchTopicPromos = ( const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { const { translations } = use(ServiceContext); + const { topicDiscovery } = translations; const promosCacheRef = useRef>({}); const [topicPromos, setTopicPromos] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -80,9 +81,11 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { }; const getMoreFromText = () => { - const moreFromTopic = translations.topicDiscovery?.moreFromTopic; - if (moreFromTopic && activeTopic?.topicName) { - return moreFromTopic.replace('{topic}', activeTopic.topicName); + if (topicDiscovery?.moreFromTopic && activeTopic?.topicName) { + return topicDiscovery.moreFromTopic.replace( + '{topic}', + activeTopic.topicName, + ); } return `More from ${activeTopic?.topicName}`; }; @@ -97,7 +100,7 @@ const TopicDiscovery = ({ topics }: TopicDiscoveryProps) => { {...viewTracker} >

- {translations.topicDiscovery?.heading ?? 'Discover more'} + {topicDiscovery?.heading ?? 'Discover more'}

Date: Wed, 6 May 2026 11:44:38 +0100 Subject: [PATCH 80/89] Only show gradient when scrolled --- .../components/TopicDiscovery/ScrollableTabs/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx index 052d4c9f406..21ebc838a37 100644 --- a/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx +++ b/src/app/components/TopicDiscovery/ScrollableTabs/index.tsx @@ -85,7 +85,9 @@ const ScrollableTabs = ({ > -
-