Skip to content
17 changes: 16 additions & 1 deletion packages/dev/s2-docs/src/ComponentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,14 @@ function getDefaultIllustration(href: string) {
return AdobeDefaultSvg;
}

function getReleaseVersionLabel(href: string) {
let match = href.match(/releases\/(v[\w-]+)\.html$/i);
if (!match) {
return null;
}
return match[1].replace(/-/g, '.');
}

interface ComponentCardProps extends Omit<CardProps, 'children'> {
name: string,
href: string,
Expand All @@ -276,7 +284,14 @@ export function ComponentCard({id, name, href, description, size, ...otherProps}
let IllustrationComponent = componentIllustrations[name] || getDefaultIllustration(href);
let overrides = propOverrides[name] || {};
let preview;
if (href.includes('react-aria/examples/') && !href.endsWith('index.html')) {
let releaseVersion = getReleaseVersionLabel(href);
if (releaseVersion) {
preview = (
<div className={style({width: '100%', aspectRatio: '3 / 2', backgroundColor: 'var(--anatomy-gray-100)', display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 0})}>
<span className={style({font: 'heading-lg', color: 'var(--anatomy-gray-900)'})}>{releaseVersion}</span>
</div>
);
} else if (href.includes('react-aria/examples/') && !href.endsWith('index.html')) {
preview = <ExampleImage name={href} />;
} else {
preview = (
Expand Down
70 changes: 52 additions & 18 deletions packages/dev/s2-docs/src/IllustrationCards.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
'use client';

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

type IllustrationItemType = {
id: string,
Expand All @@ -22,7 +25,7 @@ const itemStyle = style({
backgroundColor: {
default: 'gray-50',
isHovered: 'gray-100',
isFocused: 'gray-100',
isFocusVisible: 'gray-100',
isSelected: 'neutral'
},
font: 'ui-sm',
Expand Down Expand Up @@ -77,6 +80,7 @@ export function IllustrationCards() {
<Radio value="generic2">Generic 2</Radio>
</RadioGroup>
)}
<CopyInfoMessage />
<Suspense fallback={<Loading />}>
<IllustrationList variant={variant} gradientStyle={gradientStyle} />
</Suspense>
Expand All @@ -93,19 +97,48 @@ function Loading() {
);
}

let handleCopyImport = (id: string, variant: string, gradientStyle: string) => {
let importText = variant === 'gradient' ?
`import ${id} from '@react-spectrum/s2/illustrations/gradient/${gradientStyle}/${id}';` :
`import ${id} from '@react-spectrum/s2/illustrations/linear/${id}';`;
navigator.clipboard.writeText(importText).then(() => {
// noop
}).catch(() => {
ToastQueue.negative('Failed to copy import statement.');
});
};
function CopyInfoMessage() {
return (
<div className={style({display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4})}>
<InfoCircle styles={iconStyle({size: 'XS'})} />
<span className={style({font: 'ui'})}>Press an item to copy its import statement</span>
</div>
);
}

function useCopyImport(variant: string, gradientStyle: string) {
let [copiedId, setCopiedId] = useState<string | null>(null);
let timeout = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, []);

let handleCopyImport = useCallback((id: string) => {
if (timeout.current) {
clearTimeout(timeout.current);
}
let importText = variant === 'gradient' ?
`import ${id} from '@react-spectrum/s2/illustrations/gradient/${gradientStyle}/${id}';` :
`import ${id} from '@react-spectrum/s2/illustrations/linear/${id}';`;
navigator.clipboard.writeText(importText).then(() => {
setCopiedId(id);
timeout.current = setTimeout(() => setCopiedId(null), 2000);
}).catch(() => {
ToastQueue.negative('Failed to copy import statement.');
});
}, [variant, gradientStyle]);

return {copiedId, handleCopyImport};
}

function IllustrationList({variant, gradientStyle}) {
let items = use(loadIllustrations(variant, gradientStyle));
let {copiedId, handleCopyImport} = useCopyImport(variant, gradientStyle);
return (
<Virtualizer
layout={GridLayout}
Expand All @@ -119,7 +152,8 @@ function IllustrationList({variant, gradientStyle}) {
aria-label="Illustrations"
items={items}
layout="grid"
onAction={(item) => handleCopyImport(item.toString(), variant, gradientStyle)}
onAction={(item) => handleCopyImport(item.toString())}
dependencies={[copiedId]}
className={style({height: 560, width: '100%', maxHeight: '100%', overflow: 'auto', scrollPaddingY: 4})}
renderEmptyState={() => (
<IllustratedMessage styles={style({marginX: 'auto', marginY: 32})}>
Expand All @@ -132,25 +166,25 @@ function IllustrationList({variant, gradientStyle}) {
</Content>
</IllustratedMessage>
)}>
{(item: IllustrationItemType) => <IllustrationItem item={item} />}
{(item: IllustrationItemType) => <IllustrationItem item={item} isCopied={copiedId === item.id} />}
</ListBox>
</Virtualizer>
);
}

function IllustrationItem({item}: {item: IllustrationItemType}) {
function IllustrationItem({item, isCopied = false}: {item: IllustrationItemType, isCopied?: boolean}) {
let Illustration = item.Component;
let ref = useRef(null);
return (
<ListBoxItem id={item.id} value={item} textValue={item.id} className={itemStyle} ref={ref} style={pressScale(ref)}>
<Illustration />
{isCopied ? <Checkmark /> : <Illustration />}
<div
className={style({
display: 'flex',
alignItems: 'center',
padding: 4
})}>
{item.id}
{isCopied ? 'Copied!' : item.id}
</div>
</ListBoxItem>
);
Expand Down
11 changes: 6 additions & 5 deletions packages/dev/s2-docs/src/SearchMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,6 @@ export function SearchMenu(props: SearchMenuProps) {
);

const handleTabSelectionChange = React.useCallback((key: Key) => {
if (searchValue) {
setSearchValue('');
}
setSelectedLibrary(key as typeof selectedLibrary);
// Focus main search field of the newly selected tab
setTimeout(() => {
Expand All @@ -198,7 +195,7 @@ export function SearchMenu(props: SearchMenuProps) {
searchRef.current.focus();
}
}, 10);
}, [searchValue]);
}, []);

const handleSectionSelectionChange = React.useCallback((keys: Iterable<Key>) => {
const firstKey = Array.from(keys)[0] as string;
Expand Down Expand Up @@ -275,6 +272,10 @@ export function SearchMenu(props: SearchMenuProps) {
</TabList>
{orderedTabs.map((tab, i) => {
const tabResourceTags = getResourceTags(tab.id);
const selectedResourceTag = tabResourceTags.find(tag => tag.id === selectedTagId);
const placeholderText = selectedResourceTag
? `Search ${selectedResourceTag.name}`
: `Search ${tab.label}`;
return (
<TabPanel key={tab.id} id={tab.id}>
<Autocomplete filter={selectedTagId === 'icons' ? iconFilter : undefined}>
Expand All @@ -286,7 +287,7 @@ export function SearchMenu(props: SearchMenuProps) {
ref={searchRef}
size="L"
aria-label={`Search ${tab.label}`}
placeholder={`Search ${tab.label}`}
placeholder={placeholderText}
UNSAFE_style={{marginInlineEnd: 296, viewTransitionName: i === 0 ? 'search-menu-search-field' : 'none'} as CSSProperties}
styles={style({width: 500})} />
</div>
Expand Down
62 changes: 55 additions & 7 deletions packages/dev/s2-docs/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let isClientLink = (link: HTMLAnchorElement, pathname: string) => {
link.href &&
(!link.target || link.target === '_self') &&
link.origin === location.origin &&
link.pathname !== location.pathname &&
(link.pathname !== location.pathname || link.hash) &&
!link.hasAttribute('download') &&
link.pathname.startsWith(pathname)
);
Expand All @@ -36,7 +36,23 @@ let currentAbortController: AbortController | null = null;
// and in a React transition, stream in the new page. Once complete, we'll pushState to
// update the URL in the browser.
async function navigate(pathname: string, push = false) {
let [basePath] = pathname.split('#');
let [basePath, pathAnchor] = pathname.split('#');
let currentPath = location.pathname;
let isSamePageAnchor = (!basePath || basePath === currentPath) && pathAnchor;

if (isSamePageAnchor) {
if (push) {
history.pushState(null, '', pathname);
}

// Scroll to the anchor
let element = document.getElementById(pathAnchor);
if (element) {
element.scrollIntoView();
}
return;
}

let rscPath = basePath.replace('.html', '.rsc');

// Cancel any in-flight navigation
Expand Down Expand Up @@ -154,7 +170,7 @@ function clearPrefetchTimeout() {
}

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

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

// Clear prefetch timeout when pointer leaves a link
document.addEventListener('pointerout', e => {
let link = (e.target as Element).closest('a');
let link = e.target instanceof Element ? e.target.closest('a') : null;
if (link && link === currentPrefetchLink) {
clearPrefetchTimeout();
}
}, true);

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

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

// Clear prefetch timeout when focus leaves a link
document.addEventListener('blur', e => {
let link = (e.target as Element).closest('a');
let link = e.target instanceof Element ? e.target.closest('a') : null;
if (link && link === currentPrefetchLink) {
clearPrefetchTimeout();
}
}, true);

// Intercept link clicks to perform RSC navigation.
document.addEventListener('click', e => {
let link = (e.target as Element).closest('a');
let link = e.target instanceof Element ? e.target.closest('a') : null;
let publicUrl = process.env.PUBLIC_URL || '/';
let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl;
if (
Expand All @@ -227,3 +243,35 @@ document.addEventListener('click', e => {
window.addEventListener('popstate', () => {
navigate(location.pathname + location.search + location.hash);
});

function scrollToCurrentHash() {
if (!location.hash || location.hash === '#') {
return;
}

let anchorId = location.hash.slice(1);
try {
anchorId = decodeURIComponent(anchorId);
} catch {
// Fall back to raw hash
}

if (!anchorId) {
return;
}

requestAnimationFrame(() => {
let element = document.getElementById(anchorId);
if (element) {
element.scrollIntoView();
}
});
}

if (document.readyState === 'complete' || document.readyState === 'interactive') {
scrollToCurrentHash();
} else {
window.addEventListener('DOMContentLoaded', () => {
scrollToCurrentHash();
}, {once: true});
}
3 changes: 3 additions & 0 deletions packages/dev/s2-docs/src/prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export function prefetchRoute(pathname: string) {
prefetchPromises.delete(rscPath);
return Promise.reject<ReactElement>(new Error('Prefetch failed'));
});

// Silently handle prefetch failures
prefetchPromise.catch(() => {});

prefetchPromises.set(rscPath, prefetchPromise);
}
Expand Down