Skip to content

Commit f654238

Browse files
tsahimatsliahclaude
andcommitted
feat(explore): unified Explore top nav across lobby and topic pages
Add a shared ExploreTopicNav chip bar shown at the top of both the lobby and every topic page: an "Explore" entry that returns to the lobby (active on the lobby), followed by the user's selected/followed tags and then recommended tags. On a topic page the current tag is pinned first and marked active. This replaces the topic page's related-tags bar so navigation stays consistent (Medium's topic-bar pattern), with roving keyboard nav and a gradient fade. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 01d9683 commit f654238

4 files changed

Lines changed: 162 additions & 61 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import { QueryClient } from '@tanstack/react-query';
4+
import { TestBootProvider } from '../../../__tests__/helpers/boot';
5+
import { ExploreTopicNav } from './ExploreTopicNav';
6+
7+
const renderComponent = (props: Partial<{ activeTag: string }> = {}) =>
8+
render(
9+
<TestBootProvider client={new QueryClient()}>
10+
<ExploreTopicNav recommendedTags={['vue', 'react']} {...props} />
11+
</TestBootProvider>,
12+
);
13+
14+
describe('ExploreTopicNav', () => {
15+
it('should always link Explore back to the lobby', () => {
16+
renderComponent();
17+
18+
expect(screen.getByText('Explore').closest('a')).toHaveAttribute(
19+
'href',
20+
expect.stringContaining('explore'),
21+
);
22+
});
23+
24+
it('should render recommended tags linking to their topic page', () => {
25+
renderComponent();
26+
27+
expect(screen.getByText('#vue').closest('a')).toHaveAttribute(
28+
'href',
29+
expect.stringContaining('explore/vue'),
30+
);
31+
});
32+
33+
it('should mark the active tag as current and pin it first', () => {
34+
renderComponent({ activeTag: 'react' });
35+
36+
const activeChip = screen.getByText('#react').closest('a');
37+
expect(activeChip).toHaveAttribute('aria-current', 'page');
38+
});
39+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { ReactElement } from 'react';
2+
import React, { useMemo } from 'react';
3+
import classNames from 'classnames';
4+
import Link from '../utilities/Link';
5+
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
6+
import { HashtagIcon } from '../icons';
7+
import { IconSize } from '../Icon';
8+
import useFeedSettings from '../../hooks/useFeedSettings';
9+
import { getExploreTagPageLink } from '../../lib/links';
10+
import { webappUrl } from '../../lib/constants';
11+
import { useChipBarNavigation } from './useChipBarNavigation';
12+
13+
interface ExploreTopicNavProps {
14+
// The tag currently being viewed (topic page) or undefined on the lobby.
15+
activeTag?: string;
16+
// Related / recommended tag names to surface after the followed ones.
17+
recommendedTags?: string[];
18+
className?: string;
19+
}
20+
21+
const exploreUrl = `${webappUrl}explore`;
22+
23+
// The shared top navigation for every Explore surface: an "Explore" entry that
24+
// returns to the lobby, followed by the user's selected tags and then
25+
// recommended tags — so the bar stays consistent across the lobby and each
26+
// topic page (Medium's topic-bar pattern, in our design system).
27+
export function ExploreTopicNav({
28+
activeTag,
29+
recommendedTags = [],
30+
className,
31+
}: ExploreTopicNavProps): ReactElement {
32+
const { feedSettings } = useFeedSettings();
33+
const { ref, onKeyDown } = useChipBarNavigation();
34+
35+
const tags = useMemo(() => {
36+
const followed = feedSettings?.includeTags ?? [];
37+
const followedSet = new Set(followed);
38+
// Pin the active tag first when it isn't already followed.
39+
const ordered =
40+
activeTag && !followedSet.has(activeTag)
41+
? [activeTag, ...followed]
42+
: followed;
43+
const rec = recommendedTags.filter(
44+
(tag) => tag && !followedSet.has(tag) && tag !== activeTag,
45+
);
46+
return Array.from(new Set([...ordered, ...rec]));
47+
}, [feedSettings?.includeTags, recommendedTags, activeTag]);
48+
49+
const isLobby = !activeTag;
50+
51+
return (
52+
<div className={classNames('relative w-full', className)}>
53+
<nav aria-label="Explore navigation">
54+
<div
55+
ref={ref}
56+
onKeyDown={onKeyDown}
57+
role="toolbar"
58+
aria-orientation="horizontal"
59+
className="no-scrollbar flex items-center gap-2 overflow-x-auto pr-12"
60+
>
61+
<Link href={exploreUrl} legacyBehavior>
62+
<Button
63+
tag="a"
64+
href={exploreUrl}
65+
aria-current={isLobby ? 'page' : undefined}
66+
pressed={isLobby}
67+
size={ButtonSize.Small}
68+
variant={isLobby ? ButtonVariant.Float : ButtonVariant.Tertiary}
69+
icon={<HashtagIcon size={IconSize.Small} />}
70+
>
71+
Explore
72+
</Button>
73+
</Link>
74+
{tags.length > 0 && (
75+
<span
76+
role="separator"
77+
aria-hidden
78+
className="mx-1 h-5 w-px shrink-0 bg-border-subtlest-tertiary"
79+
/>
80+
)}
81+
{tags.map((tag) => {
82+
const isActive = tag === activeTag;
83+
const href = getExploreTagPageLink(tag);
84+
return (
85+
<Link key={tag} href={href} legacyBehavior>
86+
<Button
87+
tag="a"
88+
href={href}
89+
aria-current={isActive ? 'page' : undefined}
90+
pressed={isActive}
91+
size={ButtonSize.Small}
92+
variant={
93+
isActive ? ButtonVariant.Float : ButtonVariant.Tertiary
94+
}
95+
>
96+
#{tag}
97+
</Button>
98+
</Link>
99+
);
100+
})}
101+
</div>
102+
</nav>
103+
<div
104+
aria-hidden
105+
className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-r from-transparent to-background-default"
106+
/>
107+
</div>
108+
);
109+
}

packages/shared/src/components/explore/ExploreTopicPage.tsx

Lines changed: 7 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,9 @@ import type { UserShortProfile } from '../../lib/user';
5252
import { SponsoredTagHero } from '../brand/SponsoredTagHero';
5353
import { ElementPlaceholder } from '../ElementPlaceholder';
5454
import { ExploreEntityCard } from './ExploreEntityCard';
55+
import { ExploreTopicNav } from './ExploreTopicNav';
5556
import { largeNumberFormat } from '../../lib';
56-
import { getExploreTagPageLink } from '../../lib/links';
5757
import { webappUrl } from '../../lib/constants';
58-
import { useChipBarNavigation } from './useChipBarNavigation';
5958
import {
6059
Typography,
6160
TypographyColor,
@@ -97,64 +96,6 @@ const SectionHeading = ({
9796
</Typography>
9897
);
9998

100-
const RelatedTagsBar = ({
101-
tag,
102-
tags,
103-
}: {
104-
tag: string;
105-
tags: TagsData['tags'];
106-
}): ReactElement | null => {
107-
const { ref, onKeyDown } = useChipBarNavigation();
108-
const names = (tags ?? [])
109-
.map((item) => item.name)
110-
.filter((name): name is string => !!name);
111-
112-
if (names.length === 0) {
113-
return null;
114-
}
115-
116-
return (
117-
<nav aria-label="Related topics" className="relative mx-4">
118-
<div
119-
ref={ref}
120-
onKeyDown={onKeyDown}
121-
role="toolbar"
122-
aria-orientation="horizontal"
123-
className="no-scrollbar flex items-center gap-2 overflow-x-auto pr-12"
124-
>
125-
<Link href={getExploreTagPageLink(tag)} legacyBehavior>
126-
<Button
127-
tag="a"
128-
href={getExploreTagPageLink(tag)}
129-
aria-current="page"
130-
pressed
131-
size={ButtonSize.Small}
132-
variant={ButtonVariant.Float}
133-
>
134-
#{tag}
135-
</Button>
136-
</Link>
137-
{names.map((name) => (
138-
<Link key={name} href={getExploreTagPageLink(name)} legacyBehavior>
139-
<Button
140-
tag="a"
141-
href={getExploreTagPageLink(name)}
142-
size={ButtonSize.Small}
143-
variant={ButtonVariant.Tertiary}
144-
>
145-
#{name}
146-
</Button>
147-
</Link>
148-
))}
149-
</div>
150-
<div
151-
aria-hidden
152-
className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-r from-transparent to-background-default"
153-
/>
154-
</nav>
155-
);
156-
};
157-
15899
const ENTITY_GRID =
159100
'grid grid-cols-1 gap-4 mobileL:grid-cols-2 laptop:grid-cols-3';
160101

@@ -371,7 +312,12 @@ export const ExploreTopicPage = ({
371312
</Head>
372313
)}
373314
<div className="flex w-full flex-col px-4 py-6">
374-
<RelatedTagsBar tag={tag} tags={recommendedTags} />
315+
<ExploreTopicNav
316+
activeTag={tag}
317+
recommendedTags={recommendedTags
318+
.map((relatedTag) => relatedTag.name)
319+
.filter((name): name is string => !!name)}
320+
/>
375321

376322
{/* Identity header — centered & readable; content below spans full width. */}
377323
<header className="mx-auto flex w-full max-w-[48rem] flex-col items-center gap-4 py-8 text-center">

packages/shared/src/components/explore/ExploreTopicsPage.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { TagCategory } from '../../graphql/feedSettings';
66
import useFeedSettings from '../../hooks/useFeedSettings';
77
import { ExploreCategorySection } from './ExploreCategorySection';
88
import { ExploreTopicSearch } from './ExploreTopicSearch';
9+
import { ExploreTopicNav } from './ExploreTopicNav';
910
import { useChipBarNavigation } from './useChipBarNavigation';
1011
import { getExploreTagPageLink } from '../../lib/links';
1112
import { formatKeyword } from '../../lib/strings';
@@ -152,6 +153,12 @@ export function ExploreTopicsPage({
152153

153154
return (
154155
<div className="mx-auto flex w-full max-w-screen-laptop flex-col items-center px-4 py-10 tablet:px-6">
156+
{/* Consistent top nav: Explore + your tags + recommendations. */}
157+
<ExploreTopicNav
158+
recommendedTags={popularTags?.map((tag) => tag.value) ?? []}
159+
className="mb-8"
160+
/>
161+
155162
{/* Hero */}
156163
<header className="flex w-full max-w-screen-tablet flex-col items-center gap-5 text-center">
157164
<Typography

0 commit comments

Comments
 (0)