{
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);
}
diff --git a/yarn.lock b/yarn.lock
index 6730fecdde7..d980818b879 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5895,6 +5895,19 @@ __metadata:
languageName: unknown
linkType: soft
+"@react-aria/mcp@workspace:packages/dev/mcp/react-aria":
+ version: 0.0.0-use.local
+ resolution: "@react-aria/mcp@workspace:packages/dev/mcp/react-aria"
+ dependencies:
+ "@modelcontextprotocol/sdk": "npm:^1.17.3"
+ "@swc/helpers": "npm:^0.5.0"
+ typescript: "npm:^5.8.2"
+ zod: "npm:^3.23.8"
+ bin:
+ mcp: dist/react-aria/src/index.js
+ languageName: unknown
+ linkType: soft
+
"@react-aria/menu@npm:^3.19.3, @react-aria/menu@workspace:packages/@react-aria/menu":
version: 0.0.0-use.local
resolution: "@react-aria/menu@workspace:packages/@react-aria/menu"
@@ -7224,9 +7237,9 @@ __metadata:
languageName: unknown
linkType: soft
-"@react-spectrum/mcp@workspace:packages/dev/mcp":
+"@react-spectrum/mcp@workspace:packages/dev/mcp/s2":
version: 0.0.0-use.local
- resolution: "@react-spectrum/mcp@workspace:packages/dev/mcp"
+ resolution: "@react-spectrum/mcp@workspace:packages/dev/mcp/s2"
dependencies:
"@modelcontextprotocol/sdk": "npm:^1.17.3"
"@swc/helpers": "npm:^0.5.0"
@@ -7234,7 +7247,7 @@ __metadata:
typescript: "npm:^5.8.2"
zod: "npm:^3.23.8"
bin:
- mcp: dist/index.js
+ mcp: dist/s2/src/index.js
languageName: unknown
linkType: soft