Skip to content

Commit 2a0a7dc

Browse files
tsahimatsliahclaude
andcommitted
feat(explore): tier-1 UX polish — search, a11y, canonical, skeletons
- Search: trigger autocomplete at 3 chars, keyboard navigation (↑/↓/Enter) over results, and a zero-results state that suggests popular topics; add loading skeleton chips. - Accessibility: roving-tabindex + arrow/Home/End keyboard navigation and toolbar roles on the related-tags chip bar and category quick-nav (useChipBarNavigation hook). - SEO: canonical /explore URLs on the topic page and both lobby routes so /tags and /explore don't duplicate content. - Loading skeletons for the who-to-follow rail. - Tests for the lobby and the upgraded search behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 36ac682 commit 2a0a7dc

8 files changed

Lines changed: 310 additions & 35 deletions

File tree

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

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ import { TOP_CREATORS_BY_TAG_QUERY } from '../../graphql/users';
5454
import type { UserShortProfile } from '../../lib/user';
5555
import { SponsoredTagHero } from '../brand/SponsoredTagHero';
5656
import { RelatedEntities } from '../RelatedEntities';
57+
import { ElementPlaceholder } from '../ElementPlaceholder';
5758
import UserEntityCard from '../cards/entity/UserEntityCard';
5859
import { largeNumberFormat } from '../../lib';
5960
import { getExploreTagPageLink, getTagPageLink } from '../../lib/links';
6061
import { webappUrl } from '../../lib/constants';
62+
import { useChipBarNavigation } from './useChipBarNavigation';
6163
import {
6264
Typography,
6365
TypographyColor,
@@ -90,6 +92,7 @@ const RelatedTagsBar = ({
9092
tag: string;
9193
tags: TagsData['tags'];
9294
}): ReactElement | null => {
95+
const { ref, onKeyDown } = useChipBarNavigation();
9396
const names = (tags ?? [])
9497
.map((item) => item.name)
9598
.filter((name): name is string => !!name);
@@ -99,8 +102,14 @@ const RelatedTagsBar = ({
99102
}
100103

101104
return (
102-
<div className="relative mx-4">
103-
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto pr-12">
105+
<nav aria-label="Related topics" className="relative mx-4">
106+
<div
107+
ref={ref}
108+
onKeyDown={onKeyDown}
109+
role="toolbar"
110+
aria-orientation="horizontal"
111+
className="no-scrollbar flex items-center gap-2 overflow-x-auto pr-12"
112+
>
104113
<Link href={getExploreTagPageLink(tag)} legacyBehavior>
105114
<Button
106115
tag="a"
@@ -130,7 +139,7 @@ const RelatedTagsBar = ({
130139
aria-hidden
131140
className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-r from-transparent to-background-default"
132141
/>
133-
</div>
142+
</nav>
134143
);
135144
};
136145

@@ -186,12 +195,9 @@ const WhoToFollow = ({
186195
});
187196

188197
const users = topContributors?.topCreatorsByTag ?? initialUsers;
198+
const isLoading = isPending && initialUsers.length === 0;
189199

190-
if (isPending && initialUsers.length === 0) {
191-
return null;
192-
}
193-
194-
if (!users || users.length === 0) {
200+
if (!isLoading && (!users || users.length === 0)) {
195201
return null;
196202
}
197203

@@ -206,13 +212,21 @@ const WhoToFollow = ({
206212
Who to follow
207213
</Typography>
208214
<div className="no-scrollbar flex gap-4 overflow-x-auto">
209-
{users.map((user) => (
210-
<UserEntityCard
211-
key={user.id}
212-
user={user}
213-
className={{ container: 'shrink-0' }}
214-
/>
215-
))}
215+
{isLoading
216+
? Array.from({ length: 3 }).map((_, index) => (
217+
<ElementPlaceholder
218+
// eslint-disable-next-line react/no-array-index-key
219+
key={index}
220+
className="h-40 w-80 shrink-0 rounded-16"
221+
/>
222+
))
223+
: users.map((user) => (
224+
<UserEntityCard
225+
key={user.id}
226+
user={user}
227+
className={{ container: 'shrink-0' }}
228+
/>
229+
))}
216230
</div>
217231
</section>
218232
);
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React from 'react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { QueryClient } from '@tanstack/react-query';
4+
import nock from 'nock';
5+
import { TestBootProvider } from '../../../__tests__/helpers/boot';
6+
import { mockGraphQL } from '../../../__tests__/helpers/graphql';
7+
import { SEARCH_TAGS_QUERY } from '../../graphql/feedSettings';
8+
import { ExploreTopicSearch } from './ExploreTopicSearch';
9+
10+
beforeEach(() => {
11+
nock.cleanAll();
12+
});
13+
14+
const renderComponent = () =>
15+
render(
16+
<TestBootProvider client={new QueryClient()}>
17+
<ExploreTopicSearch
18+
followedTags={new Set()}
19+
recommendedTags={['javascript', 'react']}
20+
/>
21+
</TestBootProvider>,
22+
);
23+
24+
const typeQuery = (value: string) => {
25+
const input = screen.getByPlaceholderText('Search all topics');
26+
fireEvent.input(input, { target: { value } });
27+
};
28+
29+
it('should show recommended topics before searching', () => {
30+
renderComponent();
31+
32+
expect(screen.getByText('Recommended:')).toBeInTheDocument();
33+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
34+
});
35+
36+
it('should not search below the 3 character threshold', async () => {
37+
renderComponent();
38+
typeQuery('re');
39+
40+
// Give the debounce time to (not) fire.
41+
await new Promise((resolve) => {
42+
setTimeout(resolve, 350);
43+
});
44+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
45+
});
46+
47+
it('should render search results once the query is long enough', async () => {
48+
mockGraphQL({
49+
request: { query: SEARCH_TAGS_QUERY, variables: { query: 'rea' } },
50+
result: {
51+
data: { searchTags: { query: 'rea', tags: [{ name: 'react' }] } },
52+
},
53+
});
54+
renderComponent();
55+
typeQuery('rea');
56+
57+
const result = await screen.findByText('#react');
58+
expect(result).toBeInTheDocument();
59+
expect(screen.getByRole('listbox')).toBeInTheDocument();
60+
});
61+
62+
it('should suggest popular topics on a zero-result search', async () => {
63+
mockGraphQL({
64+
request: { query: SEARCH_TAGS_QUERY, variables: { query: 'zzz' } },
65+
result: { data: { searchTags: { query: 'zzz', tags: [] } } },
66+
});
67+
renderComponent();
68+
typeQuery('zzz');
69+
70+
await waitFor(() =>
71+
expect(screen.getByText(/No topics match/)).toBeInTheDocument(),
72+
);
73+
expect(screen.getByText('Try a popular topic:')).toBeInTheDocument();
74+
});

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

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { ReactElement } from 'react';
2-
import React, { useMemo, useState } from 'react';
1+
import type { KeyboardEvent, ReactElement } from 'react';
2+
import React, { useEffect, useMemo, useState } from 'react';
33
import { useQuery } from '@tanstack/react-query';
4+
import { useRouter } from 'next/router';
45
import classNames from 'classnames';
56
import { SearchField } from '../fields/SearchField';
67
import { TagChip } from '../tags/TagChip';
@@ -9,6 +10,7 @@ import { SEARCH_TAGS_QUERY } from '../../graphql/feedSettings';
910
import { StaleTime } from '../../lib/query';
1011
import { getExploreTagPageLink } from '../../lib/links';
1112
import useDebounceFn from '../../hooks/useDebounceFn';
13+
import { ElementPlaceholder } from '../ElementPlaceholder';
1214
import {
1315
Typography,
1416
TypographyColor,
@@ -27,13 +29,20 @@ interface ExploreTopicSearchProps {
2729
className?: string;
2830
}
2931

32+
// Autocomplete kicks in at 3 characters — short enough to feel responsive,
33+
// long enough to avoid noisy single/double-letter matches.
34+
const MIN_QUERY_LENGTH = 3;
35+
const SKELETON_WIDTHS = ['w-20', 'w-16', 'w-24', 'w-20', 'w-28'];
36+
3037
export function ExploreTopicSearch({
3138
followedTags,
3239
recommendedTags = [],
3340
className,
3441
}: ExploreTopicSearchProps): ReactElement {
42+
const router = useRouter();
3543
const [inputValue, setInputValue] = useState('');
3644
const [query, setQuery] = useState('');
45+
const [activeIndex, setActiveIndex] = useState(-1);
3746
const [debouncedSetQuery] = useDebounceFn((value?: string) => {
3847
setQuery((value ?? '').trim());
3948
}, 300);
@@ -43,11 +52,13 @@ export function ExploreTopicSearch({
4352
debouncedSetQuery(value);
4453
};
4554

55+
const hasQuery = query.length >= MIN_QUERY_LENGTH;
56+
4657
const { data, isFetching } = useQuery({
4758
queryKey: ['exploreSearchTags', query],
4859
queryFn: () =>
4960
gqlClient.request<SearchTagsResult>(SEARCH_TAGS_QUERY, { query }),
50-
enabled: query.length > 1,
61+
enabled: hasQuery,
5162
staleTime: StaleTime.OneHour,
5263
});
5364

@@ -56,8 +67,32 @@ export function ExploreTopicSearch({
5667
[data],
5768
);
5869

59-
const hasQuery = query.length > 1;
70+
// Reset the keyboard cursor whenever the result set changes.
71+
useEffect(() => {
72+
setActiveIndex(-1);
73+
}, [results]);
74+
6075
const showEmpty = hasQuery && !isFetching && results.length === 0;
76+
const showSkeleton = hasQuery && isFetching && results.length === 0;
77+
78+
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
79+
if (!results.length) {
80+
return;
81+
}
82+
if (event.key === 'ArrowDown') {
83+
event.preventDefault();
84+
setActiveIndex((prev) => (prev + 1) % results.length);
85+
} else if (event.key === 'ArrowUp') {
86+
event.preventDefault();
87+
setActiveIndex((prev) => (prev <= 0 ? results.length - 1 : prev - 1));
88+
} else if (event.key === 'Enter') {
89+
const target = results[activeIndex] ?? results[0];
90+
if (target) {
91+
event.preventDefault();
92+
router.push(getExploreTagPageLink(target));
93+
}
94+
}
95+
};
6196

6297
return (
6398
<div className={classNames('flex w-full flex-col gap-4', className)}>
@@ -66,26 +101,65 @@ export function ExploreTopicSearch({
66101
placeholder="Search all topics"
67102
value={inputValue}
68103
valueChanged={onValueChange}
104+
onKeyDown={onKeyDown}
69105
aria-label="Search all topics"
106+
aria-expanded={hasQuery && results.length > 0}
107+
autoComplete="off"
70108
/>
71-
{hasQuery && (
72-
<div className="flex flex-wrap justify-center gap-2">
73-
{results.map((tag) => (
109+
{showSkeleton && (
110+
<div className="flex flex-wrap justify-center gap-2" aria-hidden>
111+
{SKELETON_WIDTHS.map((width, index) => (
112+
<ElementPlaceholder
113+
// eslint-disable-next-line react/no-array-index-key
114+
key={index}
115+
className={classNames('h-8 rounded-10', width)}
116+
/>
117+
))}
118+
</div>
119+
)}
120+
{hasQuery && results.length > 0 && (
121+
<div role="listbox" className="flex flex-wrap justify-center gap-2">
122+
{results.map((tag, index) => (
74123
<TagChip
75124
key={tag}
76125
tag={tag}
77126
size="md"
78127
isFollowed={followedTags.has(tag)}
79128
link={getExploreTagPageLink(tag)}
129+
className={classNames(
130+
index === activeIndex && 'ring-2 ring-border-subtlest-primary',
131+
)}
80132
/>
81133
))}
82-
{showEmpty && (
83-
<Typography
84-
type={TypographyType.Callout}
85-
color={TypographyColor.Tertiary}
86-
>
87-
No topics found for “{query}”.
88-
</Typography>
134+
</div>
135+
)}
136+
{showEmpty && (
137+
<div className="flex flex-col items-center gap-2">
138+
<Typography
139+
type={TypographyType.Callout}
140+
color={TypographyColor.Tertiary}
141+
>
142+
No topics match “{query}”.
143+
</Typography>
144+
{recommendedTags.length > 0 && (
145+
<div className="flex flex-wrap items-center justify-center gap-2">
146+
<Typography
147+
tag={TypographyTag.Span}
148+
type={TypographyType.Footnote}
149+
color={TypographyColor.Tertiary}
150+
>
151+
Try a popular topic:
152+
</Typography>
153+
{recommendedTags.map((tag) => (
154+
<TagChip
155+
key={tag}
156+
tag={tag}
157+
size="md"
158+
isFollowed={followedTags.has(tag)}
159+
link={getExploreTagPageLink(tag)}
160+
/>
161+
))}
162+
</div>
89163
)}
90164
</div>
91165
)}

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import useFeedSettings from '../../hooks/useFeedSettings';
66
import { TagTopList } from '../cards/Leaderboard/TagTopList';
77
import { ExploreCategorySection } from './ExploreCategorySection';
88
import { ExploreTopicSearch } from './ExploreTopicSearch';
9+
import { useChipBarNavigation } from './useChipBarNavigation';
910
import {
1011
Typography,
1112
TypographyColor,
@@ -35,6 +36,8 @@ export function ExploreTopicsPage({
3536
isLoading = false,
3637
}: ExploreTopicsPageProps): ReactElement {
3738
const { feedSettings } = useFeedSettings();
39+
const { ref: categoryNavRef, onKeyDown: onCategoryNavKeyDown } =
40+
useChipBarNavigation();
3841
const followedTags = useMemo(
3942
() => new Set(feedSettings?.includeTags ?? []),
4043
[feedSettings?.includeTags],
@@ -65,8 +68,14 @@ export function ExploreTopicsPage({
6568
<div className="mx-auto flex w-full max-w-screen-laptop flex-col px-4 py-6 tablet:px-6">
6669
{/* Category quick-nav: scrolls to the matching section below. */}
6770
{categories.length > 0 && (
68-
<div className="relative mb-6">
69-
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto">
71+
<nav aria-label="Topic categories" className="relative mb-6">
72+
<div
73+
ref={categoryNavRef}
74+
onKeyDown={onCategoryNavKeyDown}
75+
role="toolbar"
76+
aria-orientation="horizontal"
77+
className="no-scrollbar flex items-center gap-2 overflow-x-auto"
78+
>
7079
{categories.map((category) => (
7180
<button
7281
key={category.id}
@@ -83,7 +92,7 @@ export function ExploreTopicsPage({
8392
aria-hidden
8493
className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-r from-transparent to-background-default"
8594
/>
86-
</div>
95+
</nav>
8796
)}
8897

8998
<div className="flex flex-col items-center gap-6 py-4">

0 commit comments

Comments
 (0)