diff --git a/packages/dev/s2-docs/src/ComponentCard.tsx b/packages/dev/s2-docs/src/ComponentCard.tsx index 407bd5eb7dc..91f6afaa63c 100644 --- a/packages/dev/s2-docs/src/ComponentCard.tsx +++ b/packages/dev/s2-docs/src/ComponentCard.tsx @@ -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 { name: string, href: string, @@ -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 = ( +
+ {releaseVersion} +
+ ); + } else if (href.includes('react-aria/examples/') && !href.endsWith('index.html')) { preview = ; } else { preview = ( diff --git a/packages/dev/s2-docs/src/IllustrationCards.tsx b/packages/dev/s2-docs/src/IllustrationCards.tsx index be0a82112a9..f475fbd058b 100644 --- a/packages/dev/s2-docs/src/IllustrationCards.tsx +++ b/packages/dev/s2-docs/src/IllustrationCards.tsx @@ -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, @@ -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', @@ -77,6 +80,7 @@ export function IllustrationCards() { Generic 2 )} + }> @@ -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 ( +
+ + Press an item to copy its import statement +
+ ); +} + +function useCopyImport(variant: string, gradientStyle: string) { + let [copiedId, setCopiedId] = useState(null); + let timeout = useRef | 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 ( 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={() => ( @@ -132,25 +166,25 @@ function IllustrationList({variant, gradientStyle}) { )}> - {(item: IllustrationItemType) => } + {(item: IllustrationItemType) => } ); } -function IllustrationItem({item}: {item: IllustrationItemType}) { +function IllustrationItem({item, isCopied = false}: {item: IllustrationItemType, isCopied?: boolean}) { let Illustration = item.Component; let ref = useRef(null); return ( - + {isCopied ? : }
- {item.id} + {isCopied ? 'Copied!' : item.id}
); diff --git a/packages/dev/s2-docs/src/SearchMenu.tsx b/packages/dev/s2-docs/src/SearchMenu.tsx index e1674bceb79..46d1ba3d306 100644 --- a/packages/dev/s2-docs/src/SearchMenu.tsx +++ b/packages/dev/s2-docs/src/SearchMenu.tsx @@ -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(() => { @@ -198,7 +195,7 @@ export function SearchMenu(props: SearchMenuProps) { searchRef.current.focus(); } }, 10); - }, [searchValue]); + }, []); const handleSectionSelectionChange = React.useCallback((keys: Iterable) => { const firstKey = Array.from(keys)[0] as string; @@ -275,6 +272,10 @@ export function SearchMenu(props: SearchMenuProps) { {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 ( @@ -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})} /> diff --git a/packages/dev/s2-docs/src/client.tsx b/packages/dev/s2-docs/src/client.tsx index 0fe33508021..d58afb25a64 100644 --- a/packages/dev/s2-docs/src/client.tsx +++ b/packages/dev/s2-docs/src/client.tsx @@ -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) ); @@ -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 @@ -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; @@ -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; @@ -197,7 +213,7 @@ 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(); } @@ -205,7 +221,7 @@ document.addEventListener('blur', e => { // 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 ( @@ -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}); +} diff --git a/packages/dev/s2-docs/src/prefetch.ts b/packages/dev/s2-docs/src/prefetch.ts index fe3425e5e31..033e123c89f 100644 --- a/packages/dev/s2-docs/src/prefetch.ts +++ b/packages/dev/s2-docs/src/prefetch.ts @@ -25,6 +25,9 @@ export function prefetchRoute(pathname: string) { prefetchPromises.delete(rscPath); return Promise.reject(new Error('Prefetch failed')); }); + + // Silently handle prefetch failures + prefetchPromise.catch(() => {}); prefetchPromises.set(rscPath, prefetchPromise); }