Skip to content

Commit 66cbd63

Browse files
reidbarberLFDanLu
andauthored
docs: more S2 docs fixes (#9183)
* fix client listeners Element assumption * improve Illustrations search (copy feedback + message) * dynamic search placeholder if resources tag selected * silently handle prefetch failures * fix focus visible style so it doesnt linger on click * fix anchor links * don't clear search field when switching library tabs * fix anchor links on page load * better cards for releases --------- Co-authored-by: Daniel Lu <dl1644@gmail.com>
1 parent ddff6b5 commit 66cbd63

File tree

5 files changed

+132
-31
lines changed

5 files changed

+132
-31
lines changed

packages/dev/s2-docs/src/ComponentCard.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,14 @@ function getDefaultIllustration(href: string) {
266266
return AdobeDefaultSvg;
267267
}
268268

269+
function getReleaseVersionLabel(href: string) {
270+
let match = href.match(/releases\/(v[\w-]+)\.html$/i);
271+
if (!match) {
272+
return null;
273+
}
274+
return match[1].replace(/-/g, '.');
275+
}
276+
269277
interface ComponentCardProps extends Omit<CardProps, 'children'> {
270278
name: string,
271279
href: string,
@@ -276,7 +284,14 @@ export function ComponentCard({id, name, href, description, size, ...otherProps}
276284
let IllustrationComponent = componentIllustrations[name] || getDefaultIllustration(href);
277285
let overrides = propOverrides[name] || {};
278286
let preview;
279-
if (href.includes('react-aria/examples/') && !href.endsWith('index.html')) {
287+
let releaseVersion = getReleaseVersionLabel(href);
288+
if (releaseVersion) {
289+
preview = (
290+
<div className={style({width: '100%', aspectRatio: '3 / 2', backgroundColor: 'var(--anatomy-gray-100)', display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 0})}>
291+
<span className={style({font: 'heading-lg', color: 'var(--anatomy-gray-900)'})}>{releaseVersion}</span>
292+
</div>
293+
);
294+
} else if (href.includes('react-aria/examples/') && !href.endsWith('index.html')) {
280295
preview = <ExampleImage name={href} />;
281296
} else {
282297
preview = (

packages/dev/s2-docs/src/IllustrationCards.tsx

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
'use client';
22

33
import {Autocomplete, GridLayout, ListBox, ListBoxItem, Size, useFilter, Virtualizer} from 'react-aria-components';
4+
// eslint-disable-next-line monorepo/no-internal-import
5+
import Checkmark from '@react-spectrum/s2/illustrations/gradient/generic1/Checkmark';
46
import {Content, Heading, IllustratedMessage, pressScale, ProgressCircle, Radio, RadioGroup, SearchField, SegmentedControl, SegmentedControlItem, Text, UNSTABLE_ToastQueue as ToastQueue} from '@react-spectrum/s2';
5-
import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'};
7+
import {focusRing, iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'};
68
// @ts-ignore
79
import Gradient from '@react-spectrum/s2/icons/Gradient';
810
import {illustrationAliases} from './illustrationAliases.js';
11+
import InfoCircle from '@react-spectrum/s2/icons/InfoCircle';
912
// eslint-disable-next-line monorepo/no-internal-import
1013
import NoSearchResults from '@react-spectrum/s2/illustrations/linear/NoSearchResults';
1114
import Polygon4 from '@react-spectrum/s2/icons/Polygon4';
12-
import React, {Suspense, use, useCallback, useRef, useState} from 'react';
15+
import React, {Suspense, use, useCallback, useEffect, useRef, useState} from 'react';
1316

1417
type IllustrationItemType = {
1518
id: string,
@@ -22,7 +25,7 @@ const itemStyle = style({
2225
backgroundColor: {
2326
default: 'gray-50',
2427
isHovered: 'gray-100',
25-
isFocused: 'gray-100',
28+
isFocusVisible: 'gray-100',
2629
isSelected: 'neutral'
2730
},
2831
font: 'ui-sm',
@@ -77,6 +80,7 @@ export function IllustrationCards() {
7780
<Radio value="generic2">Generic 2</Radio>
7881
</RadioGroup>
7982
)}
83+
<CopyInfoMessage />
8084
<Suspense fallback={<Loading />}>
8185
<IllustrationList variant={variant} gradientStyle={gradientStyle} />
8286
</Suspense>
@@ -93,19 +97,48 @@ function Loading() {
9397
);
9498
}
9599

96-
let handleCopyImport = (id: string, variant: string, gradientStyle: string) => {
97-
let importText = variant === 'gradient' ?
98-
`import ${id} from '@react-spectrum/s2/illustrations/gradient/${gradientStyle}/${id}';` :
99-
`import ${id} from '@react-spectrum/s2/illustrations/linear/${id}';`;
100-
navigator.clipboard.writeText(importText).then(() => {
101-
// noop
102-
}).catch(() => {
103-
ToastQueue.negative('Failed to copy import statement.');
104-
});
105-
};
100+
function CopyInfoMessage() {
101+
return (
102+
<div className={style({display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4})}>
103+
<InfoCircle styles={iconStyle({size: 'XS'})} />
104+
<span className={style({font: 'ui'})}>Press an item to copy its import statement</span>
105+
</div>
106+
);
107+
}
108+
109+
function useCopyImport(variant: string, gradientStyle: string) {
110+
let [copiedId, setCopiedId] = useState<string | null>(null);
111+
let timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
112+
113+
useEffect(() => {
114+
return () => {
115+
if (timeout.current) {
116+
clearTimeout(timeout.current);
117+
}
118+
};
119+
}, []);
120+
121+
let handleCopyImport = useCallback((id: string) => {
122+
if (timeout.current) {
123+
clearTimeout(timeout.current);
124+
}
125+
let importText = variant === 'gradient' ?
126+
`import ${id} from '@react-spectrum/s2/illustrations/gradient/${gradientStyle}/${id}';` :
127+
`import ${id} from '@react-spectrum/s2/illustrations/linear/${id}';`;
128+
navigator.clipboard.writeText(importText).then(() => {
129+
setCopiedId(id);
130+
timeout.current = setTimeout(() => setCopiedId(null), 2000);
131+
}).catch(() => {
132+
ToastQueue.negative('Failed to copy import statement.');
133+
});
134+
}, [variant, gradientStyle]);
135+
136+
return {copiedId, handleCopyImport};
137+
}
106138

107139
function IllustrationList({variant, gradientStyle}) {
108140
let items = use(loadIllustrations(variant, gradientStyle));
141+
let {copiedId, handleCopyImport} = useCopyImport(variant, gradientStyle);
109142
return (
110143
<Virtualizer
111144
layout={GridLayout}
@@ -119,7 +152,8 @@ function IllustrationList({variant, gradientStyle}) {
119152
aria-label="Illustrations"
120153
items={items}
121154
layout="grid"
122-
onAction={(item) => handleCopyImport(item.toString(), variant, gradientStyle)}
155+
onAction={(item) => handleCopyImport(item.toString())}
156+
dependencies={[copiedId]}
123157
className={style({height: 560, width: '100%', maxHeight: '100%', overflow: 'auto', scrollPaddingY: 4})}
124158
renderEmptyState={() => (
125159
<IllustratedMessage styles={style({marginX: 'auto', marginY: 32})}>
@@ -132,25 +166,25 @@ function IllustrationList({variant, gradientStyle}) {
132166
</Content>
133167
</IllustratedMessage>
134168
)}>
135-
{(item: IllustrationItemType) => <IllustrationItem item={item} />}
169+
{(item: IllustrationItemType) => <IllustrationItem item={item} isCopied={copiedId === item.id} />}
136170
</ListBox>
137171
</Virtualizer>
138172
);
139173
}
140174

141-
function IllustrationItem({item}: {item: IllustrationItemType}) {
175+
function IllustrationItem({item, isCopied = false}: {item: IllustrationItemType, isCopied?: boolean}) {
142176
let Illustration = item.Component;
143177
let ref = useRef(null);
144178
return (
145179
<ListBoxItem id={item.id} value={item} textValue={item.id} className={itemStyle} ref={ref} style={pressScale(ref)}>
146-
<Illustration />
180+
{isCopied ? <Checkmark /> : <Illustration />}
147181
<div
148182
className={style({
149183
display: 'flex',
150184
alignItems: 'center',
151185
padding: 4
152186
})}>
153-
{item.id}
187+
{isCopied ? 'Copied!' : item.id}
154188
</div>
155189
</ListBoxItem>
156190
);

packages/dev/s2-docs/src/SearchMenu.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,6 @@ export function SearchMenu(props: SearchMenuProps) {
186186
);
187187

188188
const handleTabSelectionChange = React.useCallback((key: Key) => {
189-
if (searchValue) {
190-
setSearchValue('');
191-
}
192189
setSelectedLibrary(key as typeof selectedLibrary);
193190
// Focus main search field of the newly selected tab
194191
setTimeout(() => {
@@ -198,7 +195,7 @@ export function SearchMenu(props: SearchMenuProps) {
198195
searchRef.current.focus();
199196
}
200197
}, 10);
201-
}, [searchValue]);
198+
}, []);
202199

203200
const handleSectionSelectionChange = React.useCallback((keys: Iterable<Key>) => {
204201
const firstKey = Array.from(keys)[0] as string;
@@ -275,6 +272,10 @@ export function SearchMenu(props: SearchMenuProps) {
275272
</TabList>
276273
{orderedTabs.map((tab, i) => {
277274
const tabResourceTags = getResourceTags(tab.id);
275+
const selectedResourceTag = tabResourceTags.find(tag => tag.id === selectedTagId);
276+
const placeholderText = selectedResourceTag
277+
? `Search ${selectedResourceTag.name}`
278+
: `Search ${tab.label}`;
278279
return (
279280
<TabPanel key={tab.id} id={tab.id}>
280281
<Autocomplete filter={selectedTagId === 'icons' ? iconFilter : undefined}>
@@ -286,7 +287,7 @@ export function SearchMenu(props: SearchMenuProps) {
286287
ref={searchRef}
287288
size="L"
288289
aria-label={`Search ${tab.label}`}
289-
placeholder={`Search ${tab.label}`}
290+
placeholder={placeholderText}
290291
UNSAFE_style={{marginInlineEnd: 296, viewTransitionName: i === 0 ? 'search-menu-search-field' : 'none'} as CSSProperties}
291292
styles={style({width: 500})} />
292293
</div>

packages/dev/s2-docs/src/client.tsx

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ let isClientLink = (link: HTMLAnchorElement, pathname: string) => {
1414
link.href &&
1515
(!link.target || link.target === '_self') &&
1616
link.origin === location.origin &&
17-
link.pathname !== location.pathname &&
17+
(link.pathname !== location.pathname || link.hash) &&
1818
!link.hasAttribute('download') &&
1919
link.pathname.startsWith(pathname)
2020
);
@@ -36,7 +36,23 @@ let currentAbortController: AbortController | null = null;
3636
// and in a React transition, stream in the new page. Once complete, we'll pushState to
3737
// update the URL in the browser.
3838
async function navigate(pathname: string, push = false) {
39-
let [basePath] = pathname.split('#');
39+
let [basePath, pathAnchor] = pathname.split('#');
40+
let currentPath = location.pathname;
41+
let isSamePageAnchor = (!basePath || basePath === currentPath) && pathAnchor;
42+
43+
if (isSamePageAnchor) {
44+
if (push) {
45+
history.pushState(null, '', pathname);
46+
}
47+
48+
// Scroll to the anchor
49+
let element = document.getElementById(pathAnchor);
50+
if (element) {
51+
element.scrollIntoView();
52+
}
53+
return;
54+
}
55+
4056
let rscPath = basePath.replace('.html', '.rsc');
4157

4258
// Cancel any in-flight navigation
@@ -154,7 +170,7 @@ function clearPrefetchTimeout() {
154170
}
155171

156172
document.addEventListener('pointerover', e => {
157-
let link = (e.target as Element).closest('a');
173+
let link = e.target instanceof Element ? e.target.closest('a') : null;
158174
let publicUrl = process.env.PUBLIC_URL || '/';
159175
let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl;
160176

@@ -172,14 +188,14 @@ document.addEventListener('pointerover', e => {
172188

173189
// Clear prefetch timeout when pointer leaves a link
174190
document.addEventListener('pointerout', e => {
175-
let link = (e.target as Element).closest('a');
191+
let link = e.target instanceof Element ? e.target.closest('a') : null;
176192
if (link && link === currentPrefetchLink) {
177193
clearPrefetchTimeout();
178194
}
179195
}, true);
180196

181197
document.addEventListener('focus', e => {
182-
let link = (e.target as Element).closest('a');
198+
let link = e.target instanceof Element ? e.target.closest('a') : null;
183199
let publicUrl = process.env.PUBLIC_URL || '/';
184200
let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl;
185201

@@ -197,15 +213,15 @@ document.addEventListener('focus', e => {
197213

198214
// Clear prefetch timeout when focus leaves a link
199215
document.addEventListener('blur', e => {
200-
let link = (e.target as Element).closest('a');
216+
let link = e.target instanceof Element ? e.target.closest('a') : null;
201217
if (link && link === currentPrefetchLink) {
202218
clearPrefetchTimeout();
203219
}
204220
}, true);
205221

206222
// Intercept link clicks to perform RSC navigation.
207223
document.addEventListener('click', e => {
208-
let link = (e.target as Element).closest('a');
224+
let link = e.target instanceof Element ? e.target.closest('a') : null;
209225
let publicUrl = process.env.PUBLIC_URL || '/';
210226
let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl;
211227
if (
@@ -227,3 +243,35 @@ document.addEventListener('click', e => {
227243
window.addEventListener('popstate', () => {
228244
navigate(location.pathname + location.search + location.hash);
229245
});
246+
247+
function scrollToCurrentHash() {
248+
if (!location.hash || location.hash === '#') {
249+
return;
250+
}
251+
252+
let anchorId = location.hash.slice(1);
253+
try {
254+
anchorId = decodeURIComponent(anchorId);
255+
} catch {
256+
// Fall back to raw hash
257+
}
258+
259+
if (!anchorId) {
260+
return;
261+
}
262+
263+
requestAnimationFrame(() => {
264+
let element = document.getElementById(anchorId);
265+
if (element) {
266+
element.scrollIntoView();
267+
}
268+
});
269+
}
270+
271+
if (document.readyState === 'complete' || document.readyState === 'interactive') {
272+
scrollToCurrentHash();
273+
} else {
274+
window.addEventListener('DOMContentLoaded', () => {
275+
scrollToCurrentHash();
276+
}, {once: true});
277+
}

packages/dev/s2-docs/src/prefetch.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export function prefetchRoute(pathname: string) {
2525
prefetchPromises.delete(rscPath);
2626
return Promise.reject<ReactElement>(new Error('Prefetch failed'));
2727
});
28+
29+
// Silently handle prefetch failures
30+
prefetchPromise.catch(() => {});
2831

2932
prefetchPromises.set(rscPath, prefetchPromise);
3033
}

0 commit comments

Comments
 (0)