From ba42d1850a1024edbcb0435d01ca2bf715f4729a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 2 Feb 2026 11:50:00 -0800 Subject: [PATCH 1/7] chore: rename col index data attribute (#9571) * rename col index data attribute * rename renderProps as well --- packages/react-aria-components/src/Table.tsx | 8 ++-- .../react-aria-components/test/Table.test.js | 44 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index e0f5fcf67a8..c1096ead793 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1328,7 +1328,7 @@ export interface CellRenderProps { /** * The index of the column that this cell belongs to. Respects col spanning. */ - colIndex?: number | null + columnIndex?: number | null } export interface CellProps extends RenderProps, GlobalDOMAttributes { @@ -1377,7 +1377,7 @@ export const Cell = /*#__PURE__*/ createLeafComponent(TableCellNode, (props: Cel let {hoverProps, isHovered} = useHover({}); let isSelected = cell.parentKey != null ? state.selectionManager.isSelected(cell.parentKey) : false; // colIndex is null, when there is so span, falling back to using the index - let colIndex = cell.colIndex || cell.index; + let columnIndex = cell.colIndex || cell.index; let renderProps = useRenderProps({ ...props, @@ -1390,7 +1390,7 @@ export const Cell = /*#__PURE__*/ createLeafComponent(TableCellNode, (props: Cel isHovered, isSelected, id: cell.key, - colIndex + columnIndex } }); @@ -1405,7 +1405,7 @@ export const Cell = /*#__PURE__*/ createLeafComponent(TableCellNode, (props: Cel data-focus-visible={isFocusVisible || undefined} data-pressed={isPressed || undefined} data-selected={isSelected || undefined} - data-col-index={colIndex}> + data-column-index={columnIndex}> {renderProps.children} diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index c5e5d900662..3b8b948a2a0 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -904,16 +904,16 @@ describe('Table', () => { - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} @@ -925,10 +925,10 @@ describe('Table', () => { expect(cells[1]).toHaveTextContent('cell index: 1'); expect(cells[2]).toHaveTextContent('cell index: 2'); expect(cells[3]).toHaveTextContent('cell index: 3'); - expect(cells[0]).toHaveAttribute('data-col-index', '0'); - expect(cells[1]).toHaveAttribute('data-col-index', '1'); - expect(cells[2]).toHaveAttribute('data-col-index', '2'); - expect(cells[3]).toHaveAttribute('data-col-index', '3'); + expect(cells[0]).toHaveAttribute('data-column-index', '0'); + expect(cells[1]).toHaveAttribute('data-column-index', '1'); + expect(cells[2]).toHaveAttribute('data-column-index', '2'); + expect(cells[3]).toHaveAttribute('data-column-index', '3'); }); it('should support colspan with cell index', () => { @@ -943,27 +943,27 @@ describe('Table', () => { - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} - {({colIndex}) => `cell index: ${colIndex}`} + {({columnIndex}) => `cell index: ${columnIndex}`} @@ -975,19 +975,19 @@ describe('Table', () => { expect(cells[0]).toHaveTextContent('cell index: 0'); expect(cells[1]).toHaveTextContent('cell index: 2'); expect(cells[2]).toHaveTextContent('cell index: 3'); - expect(cells[0]).toHaveAttribute('data-col-index', '0'); - expect(cells[1]).toHaveAttribute('data-col-index', '2'); - expect(cells[2]).toHaveAttribute('data-col-index', '3'); + expect(cells[0]).toHaveAttribute('data-column-index', '0'); + expect(cells[1]).toHaveAttribute('data-column-index', '2'); + expect(cells[2]).toHaveAttribute('data-column-index', '3'); // second row expect(cells[3]).toHaveTextContent('cell index: 0'); expect(cells[4]).toHaveTextContent('cell index: 1'); expect(cells[5]).toHaveTextContent('cell index: 2'); expect(cells[6]).toHaveTextContent('cell index: 3'); - expect(cells[3]).toHaveAttribute('data-col-index', '0'); - expect(cells[4]).toHaveAttribute('data-col-index', '1'); - expect(cells[5]).toHaveAttribute('data-col-index', '2'); - expect(cells[6]).toHaveAttribute('data-col-index', '3'); + expect(cells[3]).toHaveAttribute('data-column-index', '0'); + expect(cells[4]).toHaveAttribute('data-column-index', '1'); + expect(cells[5]).toHaveAttribute('data-column-index', '2'); + expect(cells[6]).toHaveAttribute('data-column-index', '3'); }); it('should support columnHeader typeahead', async () => { From 54ea126d8ea6082ffe7e7d34ddf97df53309fe0b Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 2 Feb 2026 14:10:28 -0600 Subject: [PATCH 2/7] fix: store page keys with library prefix (#9560) --- packages/dev/mcp/shared/src/page-manager.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/dev/mcp/shared/src/page-manager.ts b/packages/dev/mcp/shared/src/page-manager.ts index 74fd1724e4b..4865b8aba60 100644 --- a/packages/dev/mcp/shared/src/page-manager.ts +++ b/packages/dev/mcp/shared/src/page-manager.ts @@ -29,9 +29,10 @@ export async function buildPageIndex(library: Library): Promise { const href = (m[2] || '').trim(); const description = (m[3] || '').trim() || undefined; if (!href || !/\.md$/i.test(href)) {continue;} - const key = href.replace(/\.md$/i, '').replace(/\\/g, '/'); - const name = display || path.basename(key); - const filePath = `${baseUrl}/${key}.md`; + const hrefWithoutExt = href.replace(/\.md$/i, '').replace(/\\/g, '/'); + const key = `${library}/${hrefWithoutExt}`; + const name = display || path.basename(hrefWithoutExt); + const filePath = `${baseUrl}/${hrefWithoutExt}.md`; const info: PageInfo = {key, name, description, filePath, sections: []}; pages.push(info); pageCache.set(info.key, info); From 5fee021e92e90b5a387f577d36aa65cd46917940 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 2 Feb 2026 14:38:01 -0600 Subject: [PATCH 3/7] chore: add Agent Skills (#9515) * add script to generate agent skills * init agent skills * remove generated rules * output to .well-known * clean up script * Revert "remove generated rules" This reverts commit 62b228322499d4c7edd03b2cccb9a375609648b3. * remove generated skills from repo * explicitly move .well-known in build-s2-docs * remove truncation from lists in SKILLS.md * add docs (consolidate with mcp docs) * review comments --- Makefile | 2 + package.json | 1 + packages/dev/s2-docs/package.json | 2 +- packages/dev/s2-docs/pages/react-aria/ai.mdx | 106 ++++ .../pages/react-aria/getting-started.mdx | 2 +- packages/dev/s2-docs/pages/react-aria/mcp.mdx | 91 +-- packages/dev/s2-docs/pages/s2/ai.mdx | 106 ++++ packages/dev/s2-docs/pages/s2/mcp.mdx | 91 +-- packages/dev/s2-docs/pages/s2/styling.mdx | 2 +- .../s2-docs/scripts/generateAgentSkills.mjs | 592 ++++++++++++++++++ 10 files changed, 822 insertions(+), 173 deletions(-) create mode 100644 packages/dev/s2-docs/pages/react-aria/ai.mdx create mode 100644 packages/dev/s2-docs/pages/s2/ai.mdx create mode 100644 packages/dev/s2-docs/scripts/generateAgentSkills.mjs diff --git a/Makefile b/Makefile index 868869b4f1f..6d39a12ee2d 100644 --- a/Makefile +++ b/Makefile @@ -180,7 +180,9 @@ build-s2-docs: mkdir -p dist/s2-docs/react-aria/$(PUBLIC_URL) mkdir -p dist/s2-docs/s2/$(PUBLIC_URL) mv packages/dev/s2-docs/dist/react-aria/* dist/s2-docs/react-aria/$(PUBLIC_URL) + if [ -d packages/dev/s2-docs/dist/react-aria/.well-known ]; then mv packages/dev/s2-docs/dist/react-aria/.well-known dist/s2-docs/react-aria/$(PUBLIC_URL); fi mv packages/dev/s2-docs/dist/s2/* dist/s2-docs/s2/$(PUBLIC_URL) + if [ -d packages/dev/s2-docs/dist/s2/.well-known ]; then mv packages/dev/s2-docs/dist/s2/.well-known dist/s2-docs/s2/$(PUBLIC_URL); fi # Build old docs pages, which get inter-mixed with the new pages # TODO: We probably don't need to build this on every PR diff --git a/package.json b/package.json index 1b409e1afb8..9760520038d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "build:s2-docs": "yarn workspace @react-spectrum/s2-docs build", "check:s2-docs-build": "node packages/dev/s2-docs/scripts/validateS2DocsBuild.mjs", "build:mcp": "yarn workspace @react-spectrum/mcp build && yarn workspace @react-aria/mcp build", + "generate:skills": "node packages/dev/s2-docs/scripts/generateAgentSkills.mjs", "start:mcp": "yarn workspace @react-spectrum/s2-docs generate:md && yarn build:mcp && node packages/dev/mcp/s2/dist/index.js && node packages/dev/mcp/react-aria/dist/index.js", "test:mcp": "yarn build:s2-docs && yarn build:mcp && node packages/dev/mcp/scripts/smoke-list-pages.mjs", "test": "cross-env STRICT_MODE=1 VIRT_ON=1 yarn jest", diff --git a/packages/dev/s2-docs/package.json b/packages/dev/s2-docs/package.json index 1c5b8f2d3da..1e050fded2f 100644 --- a/packages/dev/s2-docs/package.json +++ b/packages/dev/s2-docs/package.json @@ -6,7 +6,7 @@ "start": "DOCS_ENV=dev parcel 'pages/**/*.mdx' --config .parcelrc-s2-docs", "start:s2": "DOCS_ENV=dev LIBRARY=s2 parcel 'pages/s2/**/*.mdx' -p 4321 --config .parcelrc-s2-docs --dist-dir dist/s2 --cache-dir ../../../.parcel-cache/s2", "start:react-aria": "DOCS_ENV=dev LIBRARY=react-aria parcel 'pages/react-aria/**/*.mdx' -p 1234 --config .parcelrc-s2-docs --dist-dir dist/react-aria --cache-dir ../../../.parcel-cache/react-aria", - "build": "yarn build:s2 --public-url $PUBLIC_URL && yarn build:react-aria --public-url $PUBLIC_URL", + "build": "yarn build:s2 --public-url $PUBLIC_URL && yarn build:react-aria --public-url $PUBLIC_URL && node scripts/generateAgentSkills.mjs", "build:s2": "LIBRARY=s2 parcel build 'pages/s2/**/*.mdx' --config .parcelrc-s2-docs --dist-dir dist/s2 --cache-dir ../../../.parcel-cache/s2", "build:react-aria": "LIBRARY=react-aria parcel build 'pages/react-aria/**/*.mdx' --config .parcelrc-s2-docs --dist-dir dist/react-aria --cache-dir ../../../.parcel-cache/react-aria", "generate:og": "node scripts/generateOGImages.mjs", diff --git a/packages/dev/s2-docs/pages/react-aria/ai.mdx b/packages/dev/s2-docs/pages/react-aria/ai.mdx new file mode 100644 index 00000000000..3fe2f9030a9 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/ai.mdx @@ -0,0 +1,106 @@ +import {Layout} from '../../src/Layout'; +import {StaticTable} from '../../src/StaticTable'; +import {Command} from '../../src/Command'; +import {Link} from '@react-spectrum/s2'; +export default Layout; + +export const section = 'Guides'; +export const description = 'How to use the React Aria MCP Server, Agent Skills, and more to help you build with AI.'; +export const tags = ['ai', 'mcp', 'agent', 'skills', 'llms.txt', 'markdown']; + +# Working with AI + +Learn how to use the React Aria MCP Server, Agent Skills, and more to help you build with AI. + +## MCP Server + +### Pre-requisites + +[Node.js](https://nodejs.org/) must be installed on your system to run the MCP server. + +### Using with an MCP client + +Add the server to your MCP client configuration (the exact file and schema may depend on your client). + +```js +{ + "mcpServers": { + "React Aria": { + "command": "npx", + "args": ["@react-aria/mcp@latest"] + } + } +} +``` + +### Cursor + + + + + + Add to Cursor + + + +Or follow Cursor's MCP install [guide](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) and use the standard config above. + +### VS Code + + + Install in Visual Studio Code + + +Or follow VS Code's MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server) and use the standard config above. You can also add the server using the VS Code CLI: + + + +### Claude Code + +Use the Claude Code CLI to add the server: + + + +For more information, see the [Claude Code MCP documentation](https://docs.claude.com/en/docs/claude-code/mcp). + +### Codex + +Create or edit the configuration file `~/.codex/config.toml` and add: + +``` +[mcp_servers.react-aria] +command = "npx" +args = ["@react-aria/mcp@latest"] +``` + +For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers). + +### Gemini CLI + +Use the Gemini CLI to add the server: + + + +For more information, see the [Gemini CLI MCP documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#how-to-set-up-your-mcp-server). + +### Windsurf + +Follow the Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp) and use the standard config above. + +## Agent Skills + +[Agent Skills](https://agentskills.io) are folders of instructions, scripts, and resources that your AI coding tool can load when relevant to help with specific tasks. + +To install the React Aria skill, run: + + + +## Markdown docs + +Each page in the React Aria documentation is also available as a standalone markdown file. + +Add the `.md` extension to the URL to get the markdown version of a page. Additionally, each page has a "Copy for LLM" button which, when pressed, will copy the contents of the page to your clipboard as markdown text. + +## llms.txt + +The [llms.txt](https://react-aria.adobe.com/llms.txt) file contains a list of all the markdown pages available in the React Aria documentation. \ No newline at end of file diff --git a/packages/dev/s2-docs/pages/react-aria/getting-started.mdx b/packages/dev/s2-docs/pages/react-aria/getting-started.mdx index 22f75a589c4..e1751f9c3e4 100644 --- a/packages/dev/s2-docs/pages/react-aria/getting-started.mdx +++ b/packages/dev/s2-docs/pages/react-aria/getting-started.mdx @@ -75,7 +75,7 @@ If you're building a full component library, download a pre-built [Storybook](ht ### Working with AI -Use the menu on each page in the docs to open or copy it into your favorite AI assistant. We also have an [MCP server](mcp) which can be used directly in your IDE, and llms.txt which can help AI agents navigate the docs. +Use the menu on each page in the docs to open or copy it into your favorite AI assistant. We also have an [MCP server](ai.html#mcp-server) which can be used directly in your IDE, [Agent Skills](ai.html#agent-skills) which can be installed in your project, and llms.txt which can help AI agents navigate the docs. ## Build a component from scratch diff --git a/packages/dev/s2-docs/pages/react-aria/mcp.mdx b/packages/dev/s2-docs/pages/react-aria/mcp.mdx index 4edac5bc36c..d9423739466 100644 --- a/packages/dev/s2-docs/pages/react-aria/mcp.mdx +++ b/packages/dev/s2-docs/pages/react-aria/mcp.mdx @@ -1,86 +1,7 @@ -import {Layout} from '../../src/Layout'; -import {StaticTable} from '../../src/StaticTable'; -import {Command} from '../../src/Command'; -import {Link} from '@react-spectrum/s2'; -export default Layout; +export const hideFromSearch = true; +export const omitFromNav = true; -export const section = 'Guides'; -export const description = 'How to use the React Aria MCP Server.'; -export const tags = ['mcp', 'ai', 'documentation', 'tools']; - -# MCP Server - -Learn how to use the React Aria [MCP](https://modelcontextprotocol.io/docs/getting-started/intro) server to help AI agents browse the documentation. - -## Pre-requisites - -[Node.js](https://nodejs.org/) must be installed on your system to run the MCP server. - -## Using with an MCP client - -Add the server to your MCP client configuration (the exact file and schema may depend on your client). - -```js -{ - "mcpServers": { - "React Aria": { - "command": "npx", - "args": ["@react-aria/mcp@latest"] - } - } -} -``` - -### Cursor - - - - - - Add to Cursor - - - -Or follow Cursor's MCP install [guide](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) and use the standard config above. - -### VS Code - - - Install in Visual Studio Code - - -Or follow VS Code's MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server) and use the standard config above. You can also add the server using the VS Code CLI: - - - -### Claude Code - -Use the Claude Code CLI to add the server: - - - -For more information, see the [Claude Code MCP documentation](https://docs.claude.com/en/docs/claude-code/mcp). - -### Codex - -Create or edit the configuration file `~/.codex/config.toml` and add: - -``` -[mcp_servers.react-aria] -command = "npx" -args = ["@react-aria/mcp@latest"] -``` - -For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers). - -### Gemini CLI - -Use the Gemini CLI to add the server: - - - -For more information, see the [Gemini CLI MCP documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#how-to-set-up-your-mcp-server). - -### Windsurf - -Follow the Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp) and use the standard config above. + + MCP Server - React Aria + + diff --git a/packages/dev/s2-docs/pages/s2/ai.mdx b/packages/dev/s2-docs/pages/s2/ai.mdx new file mode 100644 index 00000000000..b7e6fcd38fd --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/ai.mdx @@ -0,0 +1,106 @@ +import {Layout} from '../../src/Layout'; +import {StaticTable} from '../../src/StaticTable'; +import {Command} from '../../src/Command'; +import {Link} from '@react-spectrum/s2'; +export default Layout; + +export const section = 'Guides'; +export const description = 'How to use the React Spectrum MCP Server, Agent Skills, and more to help you build with AI.'; +export const tags = ['ai', 'mcp', 'agent', 'skills', 'llms.txt', 'markdown']; + +# Working with AI + +Learn how to use the React Spectrum MCP Server, Agent Skills, and more to help you build with AI. + +## MCP Server + +### Pre-requisites + +[Node.js](https://nodejs.org/) must be installed on your system to run the MCP server. + +### Using with an MCP client + +Add the server to your MCP client configuration (the exact file and schema may depend on your client). + +```js +{ + "mcpServers": { + "React Spectrum (S2)": { + "command": "npx", + "args": ["@react-spectrum/mcp@latest"] + } + } +} +``` + +### Cursor + + + + + + Add to Cursor + + + +Or follow Cursor's MCP install [guide](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) and use the standard config above. + +### VS Code + + + Install in Visual Studio Code + + +Or follow VS Code's MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server) and use the standard config above. You can also add the server using the VS Code CLI: + + + +### Claude Code + +Use the Claude Code CLI to add the server: + + + +For more information, see the [Claude Code MCP documentation](https://docs.claude.com/en/docs/claude-code/mcp). + +### Codex + +Create or edit the configuration file `~/.codex/config.toml` and add: + +``` +[mcp_servers.react-spectrum-s2] +command = "npx" +args = ["@react-spectrum/mcp@latest"] +``` + +For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers). + +### Gemini CLI + +Use the Gemini CLI to add the server: + + + +For more information, see the [Gemini CLI MCP documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#how-to-set-up-your-mcp-server). + +### Windsurf + +Follow the Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp) and use the standard config above. + +## Agent Skills + +[Agent Skills](https://agentskills.io) are folders of instructions, scripts, and resources that your AI coding tool can load when relevant to help with specific tasks. + +To install the React Spectrum skill, run: + + + +## Markdown docs + +Each page in the React Spectrum documentation is also available as a standalone markdown file. + +Add the `.md` extension to the URL to get the markdown version of a page. Additionally, each page has a "Copy for LLM" button which, when pressed, will copy the contents of the page to your clipboard as markdown text. + +## llms.txt + +The [llms.txt](https://react-spectrum.adobe.com/llms.txt) file contains a list of all the markdown pages available in the React Spectrum documentation. diff --git a/packages/dev/s2-docs/pages/s2/mcp.mdx b/packages/dev/s2-docs/pages/s2/mcp.mdx index bc833cce90a..f8013dee34e 100644 --- a/packages/dev/s2-docs/pages/s2/mcp.mdx +++ b/packages/dev/s2-docs/pages/s2/mcp.mdx @@ -1,86 +1,7 @@ -import {Layout} from '../../src/Layout'; -import {StaticTable} from '../../src/StaticTable'; -import {Command} from '../../src/Command'; -import {Link} from '@react-spectrum/s2'; -export default Layout; +export const hideFromSearch = true; +export const omitFromNav = true; -export const section = 'Guides'; -export const description = 'How to use the React Spectrum MCP Server.'; -export const tags = ['mcp', 'ai', 'documentation', 'tools']; - -# MCP Server - -Learn how to use the React Spectrum [MCP](https://modelcontextprotocol.io/docs/getting-started/intro) server to help AI agents browse the documentation. - -## Pre-requisites - -[Node.js](https://nodejs.org/) must be installed on your system to run the MCP server. - -## Using with an MCP client - -Add the server to your MCP client configuration (the exact file and schema may depend on your client). - -```js -{ - "mcpServers": { - "React Spectrum (S2)": { - "command": "npx", - "args": ["@react-spectrum/mcp@latest"] - } - } -} -``` - -### Cursor - - - - - - Add to Cursor - - - -Or follow Cursor's MCP install [guide](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) and use the standard config above. - -### VS Code - - - Install in Visual Studio Code - - -Or follow VS Code's MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server) and use the standard config above. You can also add the server using the VS Code CLI: - - - -### Claude Code - -Use the Claude Code CLI to add the server: - - - -For more information, see the [Claude Code MCP documentation](https://docs.claude.com/en/docs/claude-code/mcp). - -### Codex - -Create or edit the configuration file `~/.codex/config.toml` and add: - -``` -[mcp_servers.react-spectrum-s2] -command = "npx" -args = ["@react-spectrum/mcp@latest"] -``` - -For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers). - -### Gemini CLI - -Use the Gemini CLI to add the server: - - - -For more information, see the [Gemini CLI MCP documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#how-to-set-up-your-mcp-server). - -### Windsurf - -Follow the Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp) and use the standard config above. + + MCP Server - React Spectrum + + diff --git a/packages/dev/s2-docs/pages/s2/styling.mdx b/packages/dev/s2-docs/pages/s2/styling.mdx index 735b44626ca..864b2ee413f 100644 --- a/packages/dev/s2-docs/pages/s2/styling.mdx +++ b/packages/dev/s2-docs/pages/s2/styling.mdx @@ -322,7 +322,7 @@ in a non-atomic format, making it easier to scan. the `style` macros for quick prototyping. - If you are using Cursor, we offer a set of [Cursor rules](https://github.com/adobe/react-spectrum/blob/main/rules/style-macro.mdc) to use when developing with style macros. Additionally, -we have MCP servers for [React Aria](react-aria:mcp) and [React Spectrum](mcp) respectively that interface with the docs. +we have MCP servers for [React Aria](ai.html#mcp-server) and [React Spectrum](ai.html#mcp-server) respectively that interface with the docs. ## FAQ diff --git a/packages/dev/s2-docs/scripts/generateAgentSkills.mjs b/packages/dev/s2-docs/scripts/generateAgentSkills.mjs new file mode 100644 index 00000000000..933faac8f49 --- /dev/null +++ b/packages/dev/s2-docs/scripts/generateAgentSkills.mjs @@ -0,0 +1,592 @@ +#!/usr/bin/env node + +/** + * Generates Agent Skills for React Spectrum (S2) and React Aria. + * + * This script creates skills in the Agent Skills format (https://agentskills.io/specification) + * + * Usage: + * node packages/dev/s2-docs/scripts/generateAgentSkills.mjs + * + * The script will: + * 1. Run the markdown docs generation if dist doesn't exist + * 2. Create .well-known/skills directories inside the docs dist output + * 3. Copy relevant documentation to references/ subdirectories + * 4. Generate .well-known/skills/index.json for discovery + */ + +import {execSync} from 'child_process'; +import {fileURLToPath} from 'url'; +import fs from 'fs'; +import path from 'path'; +import remarkParse from 'remark-parse'; +import {unified} from 'unified'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '../../../../'); +const MARKDOWN_DOCS_DIST = path.join(REPO_ROOT, 'packages/dev/s2-docs/dist'); +const MDX_PAGES_DIR = path.join(REPO_ROOT, 'packages/dev/s2-docs/pages'); +const MARKDOWN_DOCS_SCRIPT = path.join(__dirname, 'generateMarkdownDocs.mjs'); +const WELL_KNOWN_DIR = '.well-known'; +const WELL_KNOWN_SKILLS_DIR = 'skills'; + +// Skill definitions +const SKILLS = { + 'react-spectrum-s2': { + name: 'react-spectrum-s2', + description: + 'Build accessible UI components with React Spectrum S2 (Spectrum 2). Use when developers mention React Spectrum, Spectrum 2, S2, @react-spectrum/s2, or Adobe design system components. Provides documentation for buttons, forms, dialogs, tables, date/time pickers, color pickers, and other accessible components.', + license: 'Apache-2.0', + sourceDir: 's2', + compatibility: + 'Requires a React project with @react-spectrum/s2 installed.', + metadata: { + author: 'Adobe', + website: 'https://react-spectrum.adobe.com/' + } + }, + 'react-aria': { + name: 'react-aria', + description: + 'Build accessible UI components with React Aria Components. Use when developers mention React Aria, react-aria-components, accessible components, or need unstyled accessible primitives. Provides documentation for building custom accessible UI with hooks and components.', + license: 'Apache-2.0', + sourceDir: 'react-aria', + compatibility: + 'Requires React project with react-aria-components installed.', + metadata: { + author: 'Adobe', + website: 'https://react-aria.adobe.com/' + } + } +}; + +/** + * Ensure markdown docs are generated + */ +function ensureMarkdownDocs() { + const s2LlmsTxt = path.join(MARKDOWN_DOCS_DIST, 's2', 'llms.txt'); + const reactAriaLlmsTxt = path.join(MARKDOWN_DOCS_DIST, 'react-aria', 'llms.txt'); + + if (!fs.existsSync(s2LlmsTxt) || !fs.existsSync(reactAriaLlmsTxt)) { + console.log('Markdown docs not found. Running generateMarkdownDocs.mjs...'); + execSync(`node "${MARKDOWN_DOCS_SCRIPT}"`, { + cwd: REPO_ROOT, + stdio: 'inherit' + }); + } +} + +function getWellKnownRootForLibrary(sourceDir) { + return path.join( + MARKDOWN_DOCS_DIST, + sourceDir, + WELL_KNOWN_DIR, + WELL_KNOWN_SKILLS_DIR + ); +} + +/** + * Parse llms.txt to get documentation entries + */ +function parseLlmsTxt(llmsTxtPath) { + const content = fs.readFileSync(llmsTxtPath, 'utf8'); + const entries = []; + const tree = unified().use(remarkParse).parse(content); + + const toText = (node) => { + if (!node) { + return ''; + } + if (node.type === 'text') { + return node.value; + } + if (Array.isArray(node.children)) { + return node.children.map(toText).join(''); + } + return ''; + }; + + const extractEntry = (listItem) => { + const paragraph = listItem.children?.find((child) => child.type === 'paragraph'); + if (!paragraph || !Array.isArray(paragraph.children)) { + return null; + } + + const linkIndex = paragraph.children.findIndex((child) => child.type === 'link'); + if (linkIndex === -1) { + return null; + } + + const link = paragraph.children[linkIndex]; + const title = toText(link).trim(); + const entryPath = link.url; + if (!title || !entryPath) { + return null; + } + + let description = paragraph.children + .slice(linkIndex + 1) + .map(toText) + .join('') + .trim(); + + if (description.startsWith(':')) { + description = description.slice(1).trim(); + } + + return { + title, + path: entryPath, + description + }; + }; + + const walk = (node) => { + if (!node || !Array.isArray(node.children)) { + return; + } + + for (const child of node.children) { + if (child.type === 'listItem') { + const entry = extractEntry(child); + if (entry) { + entries.push(entry); + } + } + walk(child); + } + }; + + walk(tree); + return entries; +} + +/** + * Extract the section export from an MDX file + * @param {string} mdxPath - Path to the MDX file + * @returns {string|null} - The section value or null if not found + */ +function extractSectionFromMdx(mdxPath) { + if (!fs.existsSync(mdxPath)) { + return null; + } + + const content = fs.readFileSync(mdxPath, 'utf8'); + const sectionMatch = content.match( + /export\s+const\s+section\s*=\s*['"]([^'"]+)['"]/ + ); + return sectionMatch ? sectionMatch[1] : null; +} + +/** + * Get the MDX file path for a given entry + * @param {string} sourceDir - The source directory (e.g., "s2" or "react-aria") + * @param {string} entryPath - The path from llms.txt (e.g., "Button.md") + * @returns {string} - The full path to the MDX file + */ +function getMdxPath(sourceDir, entryPath) { + // Convert .md path to .mdx path (e.g., "Button.md" -> "Button.mdx") + const mdxRelPath = entryPath.replace(/\.md$/, '.mdx'); + return path.join(MDX_PAGES_DIR, sourceDir, mdxRelPath); +} + +/** + * Map section names to category keys + */ +const SECTION_TO_CATEGORY = { + // Guides + Guides: 'guides', + Overview: 'guides', + Reference: 'guides', + 'Getting started': 'guides', + // Components (default, no section export) + Components: 'components', + // Utilities + Utilities: 'utilities', + // Interactions (hooks) + Interactions: 'interactions', + // Releases + Releases: 'releases', + // Blog + Blog: 'blog', + // Examples + Examples: 'examples', + // Internationalized + 'Date and Time': 'internationalized', + Numbers: 'internationalized' +}; + +/** + * Files to filter out per source directory + */ +const FILTERED_FILES = { + s2: ['index.md', 'error.md'], + 'react-aria': ['index.md', 'examples/index.md', 'error.md'] +}; + +/** + * Categorize documentation entries by reading section exports from MDX files + */ +function categorizeEntries(entries, sourceDir) { + const categories = { + components: [], + guides: [], + utilities: [], + interactions: [], + releases: [], + blog: [], + examples: [], + testing: [], + internationalized: [] + }; + + const filteredFiles = FILTERED_FILES[sourceDir] || []; + + for (const entry of entries) { + // Filter out specific files per source directory + if (filteredFiles.includes(entry.path)) { + continue; + } + + // Skip malformed entries + if (entry.title.length > 100) { + continue; + } + + // Check if this is a testing subpage (e.g., "CheckboxGroup/testing.md") + if (entry.path.includes('/testing.md')) { + categories.testing.push(entry); + continue; + } + + // Get the section from the original MDX file + const mdxPath = getMdxPath(sourceDir, entry.path); + const section = extractSectionFromMdx(mdxPath); + + if (section) { + const categoryKey = SECTION_TO_CATEGORY[section]; + if (categoryKey && categories[categoryKey]) { + categories[categoryKey].push(entry); + } else { + // Unknown section, default to components + console.warn( + `Unknown section "${section}" for ${entry.path}, defaulting to components` + ); + categories.components.push(entry); + } + } else { + // No section export means it's a component + categories.components.push(entry); + } + } + + return categories; +} + +/** + * Generate the SKILL.md content + */ +function generateSkillMd(skillConfig, categories, isS2) { + const frontmatter = `--- +name: ${skillConfig.name} +description: ${skillConfig.description} +license: ${skillConfig.license} +compatibility: ${skillConfig.compatibility} +metadata: + author: ${skillConfig.metadata.author} + website: ${skillConfig.metadata.website} +--- + +`; + + let content = frontmatter; + + if (isS2) { + content += `# React Spectrum S2 (Spectrum 2) + +React Spectrum S2 is Adobe's implementation of the Spectrum 2 design system in React. It provides a collection of accessible, adaptive, and high-quality UI components. + +## Documentation Structure + +The \`references/\` directory contains detailed documentation organized as follows: + +`; + } else { + content += `# React Aria Components + +React Aria Components is a library of unstyled, accessible UI components that you can style with any CSS solution. Built on top of React Aria hooks, it provides the accessibility and behavior without prescribing any visual design. + +## Documentation Structure + +The \`references/\` directory contains detailed documentation organized as follows: + +`; + } + + // Add documentation sections + if (categories.guides.length > 0) { + content += `### Guides +`; + for (const entry of categories.guides) { + content += `- [${entry.title}](references/guides/${entry.path})${entry.description ? `: ${entry.description.slice(0, 100)}` : ''}\n`; + } + content += '\n'; + } + + if (categories.components.length > 0) { + content += `### Components +`; + for (const entry of categories.components) { + content += `- [${entry.title}](references/components/${entry.path})${entry.description ? `: ${entry.description.slice(0, 80)}` : ''}\n`; + } + content += '\n'; + } + + if (categories.interactions.length > 0) { + content += `### Interactions +`; + for (const entry of categories.interactions) { + content += `- [${entry.title}](references/interactions/${entry.path})${entry.description ? `: ${entry.description.slice(0, 80)}` : ''}\n`; + } + content += '\n'; + } + + if (categories.utilities.length > 0) { + content += `### Utilities +`; + for (const entry of categories.utilities) { + content += `- [${entry.title}](references/utilities/${entry.path})${entry.description ? `: ${entry.description.slice(0, 80)}` : ''}\n`; + } + content += '\n'; + } + + if (categories.internationalized.length > 0) { + content += `### Internationalization +`; + for (const entry of categories.internationalized) { + // Strip the 'internationalized/' prefix to avoid double-nesting in the path + let refPath = entry.path; + if (refPath.startsWith('internationalized/')) { + refPath = refPath.slice('internationalized/'.length); + } + content += `- [${entry.title}](references/internationalized/${refPath})\n`; + } + content += '\n'; + } + + if (categories.testing.length > 0) { + content += `### Testing +`; + for (const entry of categories.testing) { + content += `- [${entry.title}](references/testing/${entry.path})\n`; + } + content += '\n'; + } + + return content.trimEnd() + '\n'; +} + +/** + * Copy documentation files to the skill's references directory + */ +function copyDocumentation(skillConfig, categories, skillDir) { + const refsDir = path.join(skillDir, 'references'); + const sourceDir = path.join(MARKDOWN_DOCS_DIST, skillConfig.sourceDir); + + // Create subdirectories only if they have content + const subdirs = [ + {name: 'guides', entries: categories.guides}, + {name: 'components', entries: categories.components}, + {name: 'interactions', entries: categories.interactions}, + {name: 'utilities', entries: categories.utilities}, + {name: 'testing', entries: categories.testing}, + {name: 'internationalized', entries: categories.internationalized} + ]; + for (const {name, entries} of subdirs) { + if (entries.length > 0) { + fs.mkdirSync(path.join(refsDir, name), {recursive: true}); + } + } + + // Copy files by category + const copyFile = (entry, targetSubdir, stripPrefix = null) => { + const sourcePath = path.join(sourceDir, entry.path); + if (!fs.existsSync(sourcePath)) { + return; + } + + // Optionally strip a prefix from the path to avoid double-nesting + let targetRelPath = entry.path; + if (stripPrefix && targetRelPath.startsWith(stripPrefix)) { + targetRelPath = targetRelPath.slice(stripPrefix.length); + } + + const targetPath = path.join(refsDir, targetSubdir, targetRelPath); + fs.mkdirSync(path.dirname(targetPath), {recursive: true}); + fs.copyFileSync(sourcePath, targetPath); + }; + + // Copy guides + for (const entry of categories.guides) { + copyFile(entry, 'guides'); + } + + // Copy components + for (const entry of categories.components) { + copyFile(entry, 'components'); + } + + // Copy interactions + for (const entry of categories.interactions) { + copyFile(entry, 'interactions'); + } + + // Copy utilities + for (const entry of categories.utilities) { + copyFile(entry, 'utilities'); + } + + // Copy testing docs + for (const entry of categories.testing) { + copyFile(entry, 'testing'); + } + + // Copy internationalized docs (and strip 'internationalized/' prefix to avoid double-nesting) + for (const entry of categories.internationalized) { + copyFile(entry, 'internationalized', 'internationalized/'); + } + + // Copy llms.txt + const llmsTxtSource = path.join(sourceDir, 'llms.txt'); + if (fs.existsSync(llmsTxtSource)) { + fs.copyFileSync(llmsTxtSource, path.join(refsDir, 'llms.txt')); + } +} + +function collectSkillFiles(skillDir) { + const files = []; + + const walk = (currentDir) => { + const entries = fs.readdirSync(currentDir, {withFileTypes: true}); + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + walk(entryPath); + continue; + } + if (entry.isFile()) { + files.push(entryPath); + } + } + }; + + walk(skillDir); + + return files + .map((filePath) => { + const relativePath = path.relative(skillDir, filePath); + return relativePath.split(path.sep).join('/'); + }) + .sort((a, b) => { + if (a === 'SKILL.md') {return b === 'SKILL.md' ? 0 : -1;} + if (b === 'SKILL.md') {return 1;} + return a.localeCompare(b); + }); +} + +function writeIndexJson(wellKnownRoot, skills) { + const indexPath = path.join(wellKnownRoot, 'index.json'); + const payload = {skills}; + fs.writeFileSync(indexPath, JSON.stringify(payload, null, 2) + '\n'); + console.log(`Generated ${path.relative(REPO_ROOT, indexPath)}`); +} + +/** + * Generate a single skill + */ +function generateSkill(skillConfig, wellKnownRoot) { + const skillDir = path.join(wellKnownRoot, skillConfig.name); + const isS2 = skillConfig.name === 'react-spectrum-s2'; + + // Create skill directory + fs.mkdirSync(skillDir, {recursive: true}); + + // Parse documentation entries + const llmsTxtPath = path.join( + MARKDOWN_DOCS_DIST, + skillConfig.sourceDir, + 'llms.txt' + ); + if (!fs.existsSync(llmsTxtPath)) { + console.error(`llms.txt not found at ${llmsTxtPath}`); + return; + } + + const entries = parseLlmsTxt(llmsTxtPath); + const categories = categorizeEntries(entries, skillConfig.sourceDir); + + // Generate SKILL.md + const skillMdContent = generateSkillMd(skillConfig, categories, isS2); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMdContent); + console.log( + `Generated ${path.relative(REPO_ROOT, path.join(skillDir, 'SKILL.md'))}` + ); + + // Copy documentation to references + copyDocumentation(skillConfig, categories, skillDir); + console.log( + `Copied documentation to ${path.relative(REPO_ROOT, path.join(skillDir, 'references'))}` + ); + + return skillDir; +} + + +async function main() { + console.log( + 'Generating Agent Skills for React Spectrum (S2) and React Aria...\n' + ); + + // Ensure markdown docs exist + ensureMarkdownDocs(); + + const skillsByLibrary = new Map(); + for (const config of Object.values(SKILLS)) { + const list = skillsByLibrary.get(config.sourceDir) || []; + list.push(config); + skillsByLibrary.set(config.sourceDir, list); + } + + for (const [library, skills] of skillsByLibrary.entries()) { + const wellKnownRoot = getWellKnownRootForLibrary(library); + + if (fs.existsSync(wellKnownRoot)) { + fs.rmSync(wellKnownRoot, {recursive: true}); + } + fs.mkdirSync(wellKnownRoot, {recursive: true}); + + const indexEntries = []; + for (const config of skills) { + console.log(`\nGenerating skill: ${config.name}`); + const skillDir = generateSkill(config, wellKnownRoot); + const files = collectSkillFiles(skillDir); + indexEntries.push({ + name: config.name, + description: config.description, + files + }); + } + + writeIndexJson(wellKnownRoot, indexEntries); + console.log( + `Skills directory: ${path.relative(REPO_ROOT, wellKnownRoot)}` + ); + } + + console.log('\nAgent Skills generation complete!'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 12263afecad762074d7278625b16e59aac298ab1 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 2 Feb 2026 12:43:09 -0800 Subject: [PATCH 4/7] fix: DateField keyboard cycling doesnt skip datetimes across era and daylight saving boundaries (#9561) * fix: handle japanese era and daylight savings transitions see https://github.com/adobe/react-spectrum/pull/9510#pullrequestreview-3719597275 and https://github.com/adobe/react-spectrum/pull/9510#pullrequestreview-3698698679 * add tests * test case where field isnt fully defined * fix case with timefield with placeholder zoned date time --- .../datepicker/stories/TimeField.stories.tsx | 6 ++ .../datepicker/test/DatePicker.test.js | 89 ++++++++++++++++++- .../datepicker/test/TimeField.test.js | 66 +++++++++++++- .../datepicker/src/IncompleteDate.ts | 50 ++++++++--- .../datepicker/src/useDateFieldState.ts | 2 +- 5 files changed, 194 insertions(+), 19 deletions(-) diff --git a/packages/@react-spectrum/datepicker/stories/TimeField.stories.tsx b/packages/@react-spectrum/datepicker/stories/TimeField.stories.tsx index 943c92a0dd4..2abf84748bf 100644 --- a/packages/@react-spectrum/datepicker/stories/TimeField.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/TimeField.stories.tsx @@ -80,6 +80,12 @@ Zoned.story = { name: 'zoned' }; +export const ZonedPlaceholder: TimeFieldStory = () => render({placeholderValue: parseZonedDateTime('2021-11-07T00:45-07:00[America/Los_Angeles]')}); + +ZonedPlaceholder.story = { + name: 'zoned placeholder' +}; + export const HideTimeZone: TimeFieldStory = () => render({defaultValue: parseZonedDateTime('2021-11-07T00:45-07:00[America/Los_Angeles]'), hideTimeZone: true}); HideTimeZone.story = { diff --git a/packages/@react-spectrum/datepicker/test/DatePicker.test.js b/packages/@react-spectrum/datepicker/test/DatePicker.test.js index ae8dcc86720..b8e9b492d22 100644 --- a/packages/@react-spectrum/datepicker/test/DatePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DatePicker.test.js @@ -1233,6 +1233,85 @@ describe('DatePicker', function () { it('should support using the home and end keys to jump to the min and max hour in 24 hour time', async function () { await testArrows('hour,', new CalendarDateTime(2019, 2, 3, 8), new CalendarDateTime(2019, 2, 3, 23), new CalendarDateTime(2019, 2, 3, 0), {upKey: 'End', downKey: 'Home', props: {hourCycle: 24}}); }); + + it('should support cycling through DST fall back transitions', async function () { + let onChange = jest.fn(); + let {getByLabelText} = render( + + + + ); + let segment = getByLabelText('hour,'); + act(() => {segment.focus();}); + await user.keyboard('{ArrowUp}'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(parseZonedDateTime('2021-11-07T01:45:00-07:00[America/Los_Angeles]')); + + await user.keyboard('{ArrowUp}'); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith(parseZonedDateTime('2021-11-07T01:45:00-08:00[America/Los_Angeles]')); + + await user.keyboard('{ArrowUp}'); + expect(onChange).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenLastCalledWith(parseZonedDateTime('2021-11-07T02:45:00-08:00[America/Los_Angeles]')); + + await user.keyboard('{ArrowDown}'); + expect(onChange).toHaveBeenCalledTimes(4); + expect(onChange).toHaveBeenLastCalledWith(parseZonedDateTime('2021-11-07T01:45:00-08:00[America/Los_Angeles]')); + + await user.keyboard('{ArrowDown}'); + expect(onChange).toHaveBeenCalledTimes(5); + expect(onChange).toHaveBeenLastCalledWith(parseZonedDateTime('2021-11-07T01:45:00-07:00[America/Los_Angeles]')); + + await user.keyboard('{ArrowDown}'); + expect(onChange).toHaveBeenCalledTimes(6); + expect(onChange).toHaveBeenLastCalledWith(parseZonedDateTime('2021-11-07T00:45:00-07:00[America/Los_Angeles]')); + // check that the hour is set to 12 and not 0 + expect(segment.textContent).toBe('12'); + + await user.keyboard('{ArrowDown}'); + expect(onChange).toHaveBeenCalledTimes(7); + expect(onChange).toHaveBeenLastCalledWith(parseZonedDateTime('2021-11-07T11:45:00-08:00[America/Los_Angeles]')); + }); + + it('should support cycling through DST fall back transitions even if the minutes are undefined', async function () { + let {getByLabelText} = render( + + + + ); + + let minute = getByLabelText('minute,'); + act(() => minute.focus()); + await user.keyboard('{Backspace}'); + expect(minute).toHaveAttribute('aria-valuetext', '04'); + + await user.keyboard('{Backspace}'); + expect(minute).toHaveAttribute('aria-valuetext', 'Empty'); + + let hour = getByLabelText('hour,'); + await user.keyboard('{ArrowLeft}'); + await user.keyboard('[ArrowUp]'); + expect(hour.textContent).toBe('1'); + + await user.keyboard('[ArrowUp]'); + expect(hour.textContent).toBe('1'); + + await user.keyboard('[ArrowUp]'); + expect(hour.textContent).toBe('2'); + + await user.keyboard('[ArrowDown]'); + expect(hour.textContent).toBe('1'); + + await user.keyboard('[ArrowDown]'); + expect(hour.textContent).toBe('1'); + + await user.keyboard('[ArrowDown]'); + expect(hour.textContent).toBe('12'); + + await user.keyboard('[ArrowDown]'); + expect(hour.textContent).toBe('11'); + }); }); describe('minute', function () { @@ -1285,6 +1364,10 @@ describe('DatePicker', function () { await testArrows('era,', new CalendarDate(new JapaneseCalendar(), 'heisei', 5, 2, 3), new CalendarDate(new JapaneseCalendar(), 'reiwa', 5, 2, 3), new CalendarDate(new JapaneseCalendar(), 'showa', 5, 2, 3), {locale: 'en-US-u-ca-japanese'}); }); + it('should support cycling year across era boundaries', async function () { + await testArrows('year,', new CalendarDate(new JapaneseCalendar(), 'reiwa', 1, 12, 28), new CalendarDate(new JapaneseCalendar(), 'reiwa', 2, 12, 28), new CalendarDate(new JapaneseCalendar(), 'heisei', 30, 12, 28), {locale: 'en-US-u-ca-japanese'}); + }); + it('should show and hide the era field as needed', async function () { let {queryByTestId} = render(); let year = queryByTestId('year'); @@ -2148,7 +2231,7 @@ describe('DatePicker', function () { it('resets to defaultValue when submitting form action', async () => { function Test() { const [value, formAction] = React.useActionState(() => new CalendarDate(2025, 2, 3), new CalendarDate(2020, 2, 3)); - + return (
@@ -2156,11 +2239,11 @@ describe('DatePicker', function () { ); } - + let {getByTestId} = render(); let input = document.querySelector('input[name=date]'); expect(input).toHaveValue('2020-02-03'); - + let button = getByTestId('submit'); await user.click(button); expect(input).toHaveValue('2025-02-03'); diff --git a/packages/@react-spectrum/datepicker/test/TimeField.test.js b/packages/@react-spectrum/datepicker/test/TimeField.test.js index 22dad189813..8abe3c404be 100644 --- a/packages/@react-spectrum/datepicker/test/TimeField.test.js +++ b/packages/@react-spectrum/datepicker/test/TimeField.test.js @@ -260,6 +260,66 @@ describe('TimeField', function () { expect(timezone.getAttribute('aria-label')).toBe('time zone, '); expect(within(timezone).getByText('PDT')).toBeInTheDocument(); }); + + it('should support cycling through DST fall back transitions with ZonedDateTime placeholder', async function () { + let {getByLabelText} = render( + + ); + let minute = getByLabelText('minute,'); + expect(minute).toHaveAttribute('aria-valuetext', 'Empty'); + let hour = getByLabelText('hour,'); + expect(hour).toHaveAttribute('aria-valuetext', 'Empty'); + + let segment = getByLabelText('hour,'); + act(() => {segment.focus();}); + // first arrow up sets value to the placeholder hour + await user.keyboard('{ArrowUp}'); + expect(hour.textContent).toBe('1'); + + await user.keyboard('{ArrowUp}'); + expect(hour.textContent).toBe('1'); + + await user.keyboard('{ArrowUp}'); + expect(hour.textContent).toBe('2'); + + await user.keyboard('{ArrowDown}'); + expect(hour.textContent).toBe('1'); + + await user.keyboard('{ArrowDown}'); + expect(hour.textContent).toBe('1'); + + await user.keyboard('{ArrowDown}'); + expect(hour.textContent).toBe('12'); + }); + + it('should support cycling through DST fall back transitions with ZonedDateTime defaultValue', async function () { + let onChange = jest.fn(); + let {getAllByRole} = render( + + ); + let segments = getAllByRole('spinbutton'); + + act(() => {segments[0].focus();}); + await user.keyboard('{ArrowUp}'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(parseZonedDateTime('2021-11-07T01:45:00-08:00[America/Los_Angeles]')); + + await user.keyboard('{ArrowUp}'); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledWith(parseZonedDateTime('2021-11-07T02:45:00-08:00[America/Los_Angeles]')); + + await user.keyboard('{ArrowDown}'); + expect(onChange).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenCalledWith(parseZonedDateTime('2021-11-07T01:45:00-08:00[America/Los_Angeles]')); + + await user.keyboard('{ArrowDown}'); + expect(onChange).toHaveBeenCalledTimes(4); + expect(onChange).toHaveBeenCalledWith(parseZonedDateTime('2021-11-07T01:45:00-07:00[America/Los_Angeles]')); + + await user.keyboard('{ArrowDown}'); + expect(onChange).toHaveBeenCalledTimes(5); + expect(onChange).toHaveBeenCalledWith(parseZonedDateTime('2021-11-07T00:45:00-07:00[America/Los_Angeles]')); + }); }); }); @@ -300,7 +360,7 @@ describe('TimeField', function () { it('resets to defaultValue when submitting form action', async () => { function Test() { const [value, formAction] = React.useActionState(() => new Time(10, 30), new Time(8, 30)); - + return (
@@ -308,11 +368,11 @@ describe('TimeField', function () { ); } - + let {getByTestId} = render(); let input = document.querySelector('input[name=time]'); expect(input).toHaveValue('08:30:00'); - + let button = getByTestId('submit'); await user.click(button); expect(input).toHaveValue('10:30:00'); diff --git a/packages/@react-stately/datepicker/src/IncompleteDate.ts b/packages/@react-stately/datepicker/src/IncompleteDate.ts index 3900cab30da..35fd7bbd6a9 100644 --- a/packages/@react-stately/datepicker/src/IncompleteDate.ts +++ b/packages/@react-stately/datepicker/src/IncompleteDate.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AnyDateTime, Calendar, CalendarDate} from '@internationalized/date'; +import {AnyDateTime, Calendar, CalendarDate, ZonedDateTime} from '@internationalized/date'; import {DateValue} from '@react-types/datepicker'; import {SegmentType} from './useDateFieldState'; @@ -34,6 +34,7 @@ export class IncompleteDate { minute: number | null; second: number | null; millisecond: number | null; + offset: number | null; constructor(calendar: Calendar, hourCycle: HourCycle, dateValue?: Partial> | null) { this.era = dateValue?.era ?? null; @@ -47,6 +48,7 @@ export class IncompleteDate { this.minute = dateValue?.minute ?? null; this.second = dateValue?.second ?? null; this.millisecond = dateValue?.millisecond ?? null; + this.offset = 'offset' in (dateValue ?? {}) ? (dateValue as any).offset : null; // Convert the hour from 24 hour time to the given hour cycle. if (this.hour != null) { @@ -67,6 +69,7 @@ export class IncompleteDate { res.minute = this.minute; res.second = this.second; res.millisecond = this.millisecond; + res.offset = this.offset; return res; } @@ -101,6 +104,11 @@ export class IncompleteDate { if (field === 'year' && result.era == null) { result.era = placeholder.era; } + + // clear offset when a date/time field changes since it may no longer be valid + if (field !== 'second' && field !== 'literal' && field !== 'timeZoneName') { + result.offset = null; + } return result; } @@ -112,11 +120,14 @@ export class IncompleteDate { if (field === 'year') { result.era = null; } + + // clear offset when a field is cleared since it may no longer be valid + result.offset = null; return result; } /** Increments or decrements the given field. If it is null, then it is set to the placeholder value. */ - cycle(field: SegmentType, amount: number, placeholder: DateValue): IncompleteDate { + cycle(field: SegmentType, amount: number, placeholder: DateValue, displaySegments: SegmentType[]): IncompleteDate { let res = this.copy(); // If field is null, default to placeholder. @@ -145,7 +156,7 @@ export class IncompleteDate { } case 'year': { // Use CalendarDate to cycle so that we update the era when going between 1 AD and 1 BC. - let date = new CalendarDate(this.calendar, this.era ?? placeholder.era, this.year ?? placeholder.year, 1, 1); + let date = new CalendarDate(this.calendar, this.era ?? placeholder.era, this.year ?? placeholder.year, this.month ?? 1, this.day ?? 1); date = date.cycle(field, amount, {round: field === 'year'}); res.era = date.era; res.year = date.year; @@ -159,14 +170,23 @@ export class IncompleteDate { res.day = cycleValue(res.day ?? 1, amount, 1, this.calendar.getMaximumDaysInMonth()); break; case 'hour': { - // TODO: in the case of a "fall back" DST transition, the 1am hour repeats twice. - // With this logic, it's no longer possible to select the second instance. - // Using cycle from ZonedDateTime works as expected, but requires the date already be complete. - let hours = res.hour ?? 0; - let limits = this.getSegmentLimits('hour')!; - res.hour = cycleValue(hours, amount, limits.minValue, limits.maxValue); - if (res.dayPeriod == null && 'hour' in placeholder) { - res.dayPeriod = toHourCycle(placeholder.hour, this.hourCycle)[0]; + // if date is fully defined or it is just a time field, and we have a time zone, use toValue to get a ZonedDateTime to cycle + // so DST fallback is properly handled + let hasDateSegements = displaySegments.some(s => ['year', 'month', 'day'].includes(s)); + if ('timeZone' in placeholder && (!hasDateSegements || (res.year != null && res.month != null && res.day != null))) { + let date = this.toValue(placeholder) as ZonedDateTime; + date = date.cycle('hour', amount, {hourCycle: this.hourCycle === 'h12' ? 12 : 24, round: false}); + let [dayPeriod, adjustedHour] = toHourCycle(date.hour, this.hourCycle); + res.hour = adjustedHour; + res.dayPeriod = dayPeriod; + res.offset = date.offset; + } else { + let hours = res.hour ?? 0; + let limits = this.getSegmentLimits('hour')!; + res.hour = cycleValue(hours, amount, limits.minValue, limits.maxValue); + if (res.dayPeriod == null && 'hour' in placeholder) { + res.dayPeriod = toHourCycle(placeholder.hour, this.hourCycle)[0]; + } } break; } @@ -194,7 +214,7 @@ export class IncompleteDate { hour = this.dayPeriod === 1 ? 12 : 0; } - return value.set({ + let res = value.set({ era: this.era ?? value.era, year: this.year ?? value.year, month: this.month ?? value.month, @@ -204,6 +224,12 @@ export class IncompleteDate { second: this.second ?? value.second, millisecond: this.millisecond ?? value.millisecond }); + + if ('offset' in res && this.offset != null && res.offset !== this.offset) { + res = res.add({milliseconds: res.offset - this.offset}); + } + + return res; } else { return value.set({ era: this.era ?? value.era, diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 0554481c33a..c2beec0aec5 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -283,7 +283,7 @@ export function useDateFieldState(props: DateFi ); let adjustSegment = (type: SegmentType, amount: number) => { - setValue(displayValue.cycle(type, amount, placeholder)); + setValue(displayValue.cycle(type, amount, placeholder, displaySegments)); }; let builtinValidation = useMemo(() => getValidationResult( From 24ca0acf3bf086881fb4ce0a9a83c717dfa5eec6 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:43:32 -0800 Subject: [PATCH 5/7] chore: remove unsupported calendar locale from datefield chromatic stories (#9573) --- .../datepicker/chromatic/DateField.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/datepicker/chromatic/DateField.stories.tsx b/packages/@react-spectrum/datepicker/chromatic/DateField.stories.tsx index 8f179c32b0d..ca45cf70837 100644 --- a/packages/@react-spectrum/datepicker/chromatic/DateField.stories.tsx +++ b/packages/@react-spectrum/datepicker/chromatic/DateField.stories.tsx @@ -116,7 +116,7 @@ export const ContextualHelpSideLabel: DateFieldStory = (args) => ; ArabicAlgeriaPreferences.parameters = { chromaticProvider: { - locales: ['ar-DZ-u-ca-gregory', 'ar-DZ-u-ca-islamic', 'ar-DZ-u-ca-islamic-civil', 'ar-DZ-u-ca-islamic-tbla'], + locales: ['ar-DZ-u-ca-gregory', 'ar-DZ-u-ca-islamic-civil', 'ar-DZ-u-ca-islamic-tbla'], scales: ['medium'], colorSchemes: ['light'], express: false @@ -126,7 +126,7 @@ ArabicAlgeriaPreferences.parameters = { export const ArabicUAEPreferences: DateFieldStory = (args) => ; ArabicUAEPreferences.parameters = { chromaticProvider: { - locales: ['ar-AE-u-ca-gregory', 'ar-AE-u-ca-islamic-umalqura', 'ar-AE-u-ca-islamic', 'ar-AE-u-ca-islamic-civil', 'ar-AE-u-ca-islamic-tbla'], + locales: ['ar-AE-u-ca-gregory', 'ar-AE-u-ca-islamic-umalqura', 'ar-AE-u-ca-islamic-civil', 'ar-AE-u-ca-islamic-tbla'], scales: ['medium'], colorSchemes: ['light'], express: false @@ -136,7 +136,7 @@ ArabicUAEPreferences.parameters = { export const ArabicEgyptPreferences: DateFieldStory = (args) => ; ArabicEgyptPreferences.parameters = { chromaticProvider: { - locales: ['ar-EG-u-ca-gregory', 'ar-EG-u-ca-coptic', 'ar-EG-u-ca-islamic', 'ar-EG-u-ca-islamic-civil', 'ar-EG-u-ca-islamic-tbla'], + locales: ['ar-EG-u-ca-gregory', 'ar-EG-u-ca-coptic', 'ar-EG-u-ca-islamic-civil', 'ar-EG-u-ca-islamic-tbla'], scales: ['medium'], colorSchemes: ['light'], express: false @@ -146,7 +146,7 @@ ArabicEgyptPreferences.parameters = { export const ArabicSaudiPreferences: DateFieldStory = (args) => ; ArabicSaudiPreferences.parameters = { chromaticProvider: { - locales: ['ar-SA-u-ca-islamic-umalqura', 'ar-SA-u-ca-gregory', 'ar-SA-u-ca-islamic', 'ar-SA-u-ca-islamic-rgsa'], + locales: ['ar-SA-u-ca-gregory', 'ar-SA-u-ca-islamic-umalqura'], scales: ['medium'], colorSchemes: ['light'], express: false From c45221b77b9fd710bc9f1d4ab6ce35905b60cd6b Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 2 Feb 2026 13:01:08 -0800 Subject: [PATCH 6/7] docs: Document `render` prop (#9564) * Add render prop to customization docs * merge refs in mergeProps * Document render prop to create links * lint --- .../interactions/src/PressResponder.tsx | 7 +- .../@react-aria/interactions/src/usePress.ts | 4 +- packages/@react-aria/utils/src/mergeProps.ts | 5 +- .../utils/test/mergeProps.test.jsx | 10 +++ .../dev/s2-docs/pages/react-aria/GridList.mdx | 7 +- .../dev/s2-docs/pages/react-aria/ListBox.mdx | 14 +++- .../dev/s2-docs/pages/react-aria/Menu.mdx | 14 +++- .../dev/s2-docs/pages/react-aria/Table.mdx | 7 +- .../dev/s2-docs/pages/react-aria/Tabs.mdx | 50 +++--------- .../dev/s2-docs/pages/react-aria/TagGroup.mdx | 8 +- .../dev/s2-docs/pages/react-aria/Tree.mdx | 7 +- .../pages/react-aria/customization.mdx | 34 ++++++++ .../s2-docs/pages/react-aria/frameworks.mdx | 65 +++------------- packages/dev/s2-docs/src/routers.mdx | 78 ------------------- 14 files changed, 126 insertions(+), 184 deletions(-) delete mode 100644 packages/dev/s2-docs/src/routers.mdx diff --git a/packages/@react-aria/interactions/src/PressResponder.tsx b/packages/@react-aria/interactions/src/PressResponder.tsx index de0a7c78e66..f6a598a7a68 100644 --- a/packages/@react-aria/interactions/src/PressResponder.tsx +++ b/packages/@react-aria/interactions/src/PressResponder.tsx @@ -25,10 +25,8 @@ export const PressResponder: React.forwardRef(({children, ...props}: PressResponderProps, ref: ForwardedRef) => { let isRegistered = useRef(false); let prevContext = useContext(PressResponderContext); - ref = useObjectRef(ref || prevContext?.ref); - let context = mergeProps(prevContext || {}, { + let context: any = mergeProps(prevContext || {}, { ...props, - ref, register() { isRegistered.current = true; if (prevContext) { @@ -37,7 +35,8 @@ React.forwardRef(({children, ...props}: PressResponderProps, ref: ForwardedRef { if (!isRegistered.current) { diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index d3cca266f43..22c91966655 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -99,7 +99,9 @@ function usePressResponderContext(props: PressHookProps): PressHookProps { // Consume context from and merge with props. let context = useContext(PressResponderContext); if (context) { - let {register, ...contextProps} = context; + // Prevent mergeProps from merging ref. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let {register, ref, ...contextProps} = context; props = mergeProps(contextProps, props) as PressHookProps; register(); } diff --git a/packages/@react-aria/utils/src/mergeProps.ts b/packages/@react-aria/utils/src/mergeProps.ts index c7f442fc48e..ed1a4ead348 100644 --- a/packages/@react-aria/utils/src/mergeProps.ts +++ b/packages/@react-aria/utils/src/mergeProps.ts @@ -13,6 +13,7 @@ import {chain} from './chain'; import clsx from 'clsx'; import {mergeIds} from './useId'; +import {mergeRefs} from './mergeRefs'; interface Props { [key: string]: any @@ -28,7 +29,7 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( /** * Merges multiple props objects together. Event handlers are chained, - * classNames are combined, and ids are deduplicated. + * classNames are combined, ids are deduplicated, and refs are merged. * For all other props, the last prop object overrides all previous ones. * @param args - Multiple sets of props to merge together. */ @@ -63,6 +64,8 @@ export function mergeProps(...args: T): UnionToIntersectio result[key] = clsx(a, b); } else if (key === 'id' && a && b) { result.id = mergeIds(a, b); + } else if (key === 'ref' && a && b) { + result.ref = mergeRefs(a, b); // Override others } else { result[key] = b !== undefined ? b : a; diff --git a/packages/@react-aria/utils/test/mergeProps.test.jsx b/packages/@react-aria/utils/test/mergeProps.test.jsx index cd64fd06de7..1075f65d528 100644 --- a/packages/@react-aria/utils/test/mergeProps.test.jsx +++ b/packages/@react-aria/utils/test/mergeProps.test.jsx @@ -14,6 +14,7 @@ import clsx from 'clsx'; import { mergeIds, useId } from '../src/useId'; import { mergeProps } from '../src/mergeProps'; import { render } from '@react-spectrum/test-utils-internal'; +import { createRef } from 'react'; describe('mergeProps', function () { it('handles one argument', function () { @@ -122,4 +123,13 @@ describe('mergeProps', function () { let mergedProps = mergeProps({ data: id1 }, { data: id2 }); expect(mergedProps.data).toBe(id2); }); + + it('merges refs', function () { + let ref = createRef(); + let ref1 = createRef(); + let merged = mergeProps({ref}, {ref: ref1}); + merged.ref(2); + expect(ref.current).toBe(2); + expect(ref1.current).toBe(2); + }); }); diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index 50be2017586..fd988402641 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -429,7 +429,7 @@ function AsyncLoadingExample() { ### Links -Use the `href` prop on a `` to create a link. See the [framework setup guide](frameworks) to learn how to integrate with your framework. Link interactions vary depending on the selection behavior. See the [selection guide](selection?component=GridList#selection-behavior) for more details. +Use the `href` prop on a `` to create a link. Link interactions vary depending on the selection behavior. See the [selection guide](selection?component=GridList#selection-behavior) for more details. ```tsx render docs={docs.exports.GridList} links={docs.links} props={['selectionBehavior']} initialProps={{'aria-label': 'Links', selectionMode: 'multiple'}} wide "use client"; @@ -520,6 +520,11 @@ let images = [ ``` + + Client-side routing + Due to [HTML spec limitations](https://github.com/w3c/html-aria/issues/473), GridListItems cannot be rendered as `` elements. React Aria handles link clicks with JavaScript and triggers native navigation. When using a client-side router, use the `onAction` event to programmatically trigger navigation instead of the `href` prop. + + ### Empty state ```tsx render hideImports diff --git a/packages/dev/s2-docs/pages/react-aria/ListBox.mdx b/packages/dev/s2-docs/pages/react-aria/ListBox.mdx index 07547ded718..e8e779ad899 100644 --- a/packages/dev/s2-docs/pages/react-aria/ListBox.mdx +++ b/packages/dev/s2-docs/pages/react-aria/ListBox.mdx @@ -196,7 +196,7 @@ function AsyncLoadingExample() { ### Links -Use the `href` prop on a `` to create a link. See the [framework setup guide](frameworks) to learn how to integrate with your framework. +Use the `href` prop on a `` to create a link. By default, link items in a ListBox are not selectable, and only perform navigation when the user interacts with them. However, with `selectionBehavior="replace"`, items will be selected when single clicking or pressing the Space key, and navigate to the link when double clicking or pressing the Enter key. @@ -214,6 +214,18 @@ import {ListBox, ListBoxItem} from 'react-aria-components'; ``` +By default, links are rendered as an `` element. Use the `render` prop to integrate your framework's link component. An `href` should still be passed to `ListBoxItem` so React Aria knows it is a link. + +```tsx + + 'href' in domProps + ? + :
+ } /> +``` + ### Empty state ```tsx render hideImports diff --git a/packages/dev/s2-docs/pages/react-aria/Menu.mdx b/packages/dev/s2-docs/pages/react-aria/Menu.mdx index c6b840edf5e..44a1ed04df8 100644 --- a/packages/dev/s2-docs/pages/react-aria/Menu.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Menu.mdx @@ -285,7 +285,7 @@ import {Button} from 'vanilla-starter/Button'; ### Links -Use the `href` prop on a `` to create a link. See the [framework setup guide](frameworks) to learn how to integrate with your framework. +Use the `href` prop on a `` to create a link. ```tsx render hideImports "use client"; @@ -305,6 +305,18 @@ import {Button} from 'vanilla-starter/Button'; ``` +By default, links are rendered as an `` element. Use the `render` prop to integrate your framework's link component. An `href` should still be passed to `MenuItem` so React Aria knows it is a link. + +```tsx + + 'href' in domProps + ? + :
+ } /> +``` + ### Autocomplete Popovers can include additional components as siblings of a menu. This example uses an [Autocomplete](Autocomplete) with a [SearchField](SearchField) to let the user filter the items. diff --git a/packages/dev/s2-docs/pages/react-aria/Table.mdx b/packages/dev/s2-docs/pages/react-aria/Table.mdx index 6c99f59bd79..424dc7ecd01 100644 --- a/packages/dev/s2-docs/pages/react-aria/Table.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Table.mdx @@ -261,7 +261,7 @@ function AsyncSortTable() { ### Links -Use the `href` prop on a `` to create a link. See the [framework setup guide](frameworks) to learn how to integrate with your framework. Link interactions vary depending on the selection behavior. See the [selection guide](selection) for more details. +Use the `href` prop on a `` to create a link. Link interactions vary depending on the selection behavior. See the [selection guide](selection) for more details. ```tsx render docs={docs.exports.ListBox} links={docs.links} props={['selectionBehavior']} initialProps={{'aria-label': 'Bookmarks', selectionMode: 'multiple'}} wide "use client"; @@ -295,6 +295,11 @@ import {Table, TableHeader, Column, Row, TableBody, Cell} from 'vanilla-starter/ ``` + + Client-side routing + Due to [HTML spec limitations](https://github.com/w3c/html-aria/issues/473), table rows cannot be rendered as `` elements. React Aria handles link clicks with JavaScript and triggers native navigation. When using a client-side router, use the `onAction` event to programmatically trigger navigation instead of the `href` prop. + + ### Empty state ```tsx render hideImports diff --git a/packages/dev/s2-docs/pages/react-aria/Tabs.mdx b/packages/dev/s2-docs/pages/react-aria/Tabs.mdx index f833dc213ad..9983109c84c 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tabs.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tabs.mdx @@ -186,46 +186,16 @@ function Example() { ### Links -Use the `href` prop on a `` to create a link. See the [framework setup guide](frameworks) to learn how to integrate with your framework. This example uses a simple hash-based router to sync the selected tab to the URL. - -```tsx render -"use client"; -import {Tabs, TabList, Tab, TabPanels, TabPanel} from 'vanilla-starter/Tabs'; -import {useSyncExternalStore} from 'react'; - -export default function Example() { - let hash = useSyncExternalStore(subscribe, getHash, getHashServer); - - return ( - - - {/*- begin highlight -*/} - Home - {/*- end highlight -*/} - Shared - Deleted - - - Home - Shared - Deleted - - - ); -} - -function getHash() { - return location.hash.startsWith('#/') ? location.hash : '#/'; -} - -function getHashServer() { - return '#/'; -} - -function subscribe(fn) { - addEventListener('hashchange', fn); - return () => removeEventListener('hashchange', fn); -} +Use the `href` prop on a `` to create a link. By default, links are rendered as an `` element. Use the `render` prop to integrate your framework's link component. + +```tsx + + 'href' in domProps + ? + :
+ } /> ``` ## Selection diff --git a/packages/dev/s2-docs/pages/react-aria/TagGroup.mdx b/packages/dev/s2-docs/pages/react-aria/TagGroup.mdx index dd988443d44..26add9b66f7 100644 --- a/packages/dev/s2-docs/pages/react-aria/TagGroup.mdx +++ b/packages/dev/s2-docs/pages/react-aria/TagGroup.mdx @@ -6,6 +6,7 @@ import {TagGroup as VanillaTagGroup, Tag} from 'vanilla-starter/TagGroup'; import vanillaDocs from 'docs:vanilla-starter/TagGroup'; import '../../tailwind/tailwind.css'; import Anatomy from '@react-aria/tag/docs/anatomy.svg'; +import {InlineAlert, Heading, Content} from '@react-spectrum/s2'; export const tags = ['chips', 'pills']; export const relatedPages = [{'title': 'useTagGroup', 'url': 'TagGroup/useTagGroup.html'}]; @@ -77,7 +78,7 @@ function Example() { ### Links -Use the `href` prop on a `` to create a link. See the [framework setup guide](frameworks) to learn how to integrate with your framework. +Use the `href` prop on a `` to create a link. ```tsx render "use client"; @@ -95,6 +96,11 @@ import {TagGroup, Tag} from 'vanilla-starter/TagGroup'; ``` + + Client-side routing + Due to [HTML spec limitations](https://github.com/w3c/html-aria/issues/473), tags cannot be rendered as `` elements. React Aria handles link clicks with JavaScript and triggers native navigation. When using a client-side router, use the `onAction` event to programmatically trigger navigation instead of the `href` prop. + + ## Selection Use the `selectionMode` prop to enable single or multiple selection. The selected items can be controlled via the `selectedKeys` prop, matching the `id` prop of the items. Items can be disabled with the `isDisabled` prop. See the [selection guide](selection?component=TagGroup) for more details. diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index e697c3f53d9..92555e74f77 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -187,7 +187,7 @@ function AsyncLoadingExample() { ### Links -Use the `href` prop on a `` to create a link. See the [framework setup guide](frameworks) to learn how to integrate with your framework. Link interactions vary depending on the selection behavior. See the [selection guide](selection?component=Tree#selection-behavior) for more details. +Use the `href` prop on a `` to create a link. Link interactions vary depending on the selection behavior. See the [selection guide](selection?component=Tree#selection-behavior) for more details. ```tsx render docs={docs.exports.Tree} links={docs.links} props={['selectionBehavior']} initialProps={{selectionMode: 'multiple'}} wide "use client"; @@ -219,6 +219,11 @@ import {Tree, TreeItem} from 'vanilla-starter/Tree'; ``` + + Client-side routing + Due to [HTML spec limitations](https://github.com/w3c/html-aria/issues/473), TreeItems cannot be rendered as `` elements. React Aria handles link clicks with JavaScript and triggers native navigation. When using a client-side router, use the `onAction` event to programmatically trigger navigation instead of the `href` prop. + + ### Empty state ```tsx render diff --git a/packages/dev/s2-docs/pages/react-aria/customization.mdx b/packages/dev/s2-docs/pages/react-aria/customization.mdx index c2a53be4310..7689e549b42 100644 --- a/packages/dev/s2-docs/pages/react-aria/customization.mdx +++ b/packages/dev/s2-docs/pages/react-aria/customization.mdx @@ -2,6 +2,7 @@ import {Layout} from '../../src/Layout'; export default Layout; import docs from 'docs:react-aria-components'; +import {InlineAlert, Heading, Content} from '@react-spectrum/s2'; export const section = 'Guides'; export const description = 'How to build custom component patterns.'; @@ -10,6 +11,39 @@ export const description = 'How to build custom component patterns.'; React Aria is built using a flexible and composable API. Learn how to use contexts and slots to create custom component patterns, or mix and match with the lower level Hook-based API for even more control over rendering and behavior. +## DOM elements + +Use the `render` prop on any React Aria component to render a custom component in place of the default DOM element. This accepts a function which receives the DOM props to pass through, and states such as `isPressed` and `isSelected`. + +For example, you can render a [Motion](https://motion.dev) button and use the state to drive an animation. + +```tsx +import {Button} from 'react-aria-components'; +import {motion} from 'motion/react'; + + +``` + +The `render` prop is also useful for rendering link components from client-side routers, or reusing existing presentational components. + + + Follow these rules to avoid breaking the behavior and accessibility of the component: + + + + + ## Contexts The React Aria Components API is designed around composition. Components are reused between patterns to build larger composite components. For example, there is no dedicated `NumberFieldIncrementButton` or `SelectPopover` component. Instead, the standalone [Button](Button) and [Popover](Popover) components are reused within [NumberField](NumberField) and [Select](Select). This reduces the amount of duplicate styling code you need to write and maintain, and provides powerful composition capabilities you can use in your own components. diff --git a/packages/dev/s2-docs/pages/react-aria/frameworks.mdx b/packages/dev/s2-docs/pages/react-aria/frameworks.mdx index 2455f685906..bc256d991e2 100644 --- a/packages/dev/s2-docs/pages/react-aria/frameworks.mdx +++ b/packages/dev/s2-docs/pages/react-aria/frameworks.mdx @@ -13,7 +13,6 @@ import Parcel from '../../src/icons/Parcel'; import Webpack from '../../src/icons/Webpack'; import Rollup from '../../src/icons/Rollup'; import ESBuild from '../../src/icons/Esbuild'; -import Routers from '../../src/routers.mdx'; export const section = 'Guides'; export const tags = ['framework', 'setup', 'routing', 'ssr']; @@ -26,7 +25,7 @@ export const description = 'How to integrate with your framework.'; Next.jsReact RouterParcelVitewebpackRollupESBuild - To integrate with Next.js (app router), ensure the locale on the server matches the client, and configure React Aria links to use the Next.js router. + To integrate with Next.js (app router), ensure the locale on the server matches the client. @@ -57,30 +56,18 @@ export const description = 'How to integrate with your framework.'; ``` - Create `app/provider.tsx`. This should render an `I18nProvider` to set the locale used by React Aria, and a `RouterProvider` to integrate with the Next.js router. + Create `app/provider.tsx`. This should render an `I18nProvider` to set the locale used by React Aria. ```tsx // app/provider.tsx "use client"; - import {useRouter} from 'next/navigation'; - import {RouterProvider, I18nProvider} from 'react-aria-components'; - - // Configure the type of the `routerOptions` prop on all React Aria components. - declare module 'react-aria-components' { - interface RouterConfig { - routerOptions: NonNullable['push']>[1]> - } - } + import {I18nProvider} from 'react-aria-components'; export function ClientProviders({lang, children}) { - let router = useRouter(); - return ( - - {children} - + {children} ); } @@ -89,7 +76,7 @@ export const description = 'How to integrate with your framework.'; - To integrate with React Router (framework mode), ensure the locale on the server matches the client, configure React Aria links to use client side routing, and exclude localized strings from the client bundle. If you're using declarative mode, choose your bundler above. + To integrate with React Router (framework mode), ensure the locale on the server matches the client, and exclude localized strings from the client bundle. If you're using declarative mode, choose your bundler above. @@ -149,21 +136,10 @@ export const description = 'How to integrate with your framework.'; ```tsx // app/root.tsx import {useLocale} from 'react-aria-components'; - import {useNavigate, useHref, type NavigateOptions} from 'react-router'; - - /*- begin highlight -*/ - // Configure the type of the `routerOptions` prop on all React Aria components. - declare module 'react-aria-components' { - interface RouterConfig { - routerOptions: NavigateOptions - } - } - /*- end highlight -*/ export function Layout({children}) { /*- begin highlight -*/ let {locale, direction} = useLocale(); - let navigate = useNavigate(); /*- end highlight -*/ return ( @@ -174,11 +150,7 @@ export const description = 'How to integrate with your framework.'; {/* ... */} - {/*- begin highlight -*/} - - {/*- end highlight -*/} - {children} - + {children} {/* ... */} @@ -215,12 +187,9 @@ export const description = 'How to integrate with your framework.'; - To integrate with a client-only Parcel SPA, configure React Aria links to use your client side router, and optimize the client bundle to include localized strings for your supported languages. + To integrate with a client-only Parcel SPA, and optimize the client bundle to include localized strings for your supported languages. - - - By default, React Aria includes localized strings for 30+ languages. To optimize the JavaScript bundle to include only your supported languages, install our bundler plugin. @@ -247,12 +216,9 @@ export const description = 'How to integrate with your framework.'; - To integrate with a client-only Vite SPA, configure React Aria links to use your client side router, and optimize the client bundle to include localized strings for your supported languages. + To integrate with a client-only Vite SPA, and optimize the client bundle to include localized strings for your supported languages. - - - By default, React Aria includes localized strings for 30+ languages. To optimize the JavaScript bundle to include only your supported languages, install our bundler plugin. @@ -280,12 +246,9 @@ export const description = 'How to integrate with your framework.'; - To integrate with a client-only webpack SPA, configure React Aria links to use your client side router, and optimize the client bundle to include localized strings for your supported languages. + To integrate with a client-only webpack SPA, and optimize the client bundle to include localized strings for your supported languages. - - - By default, React Aria includes localized strings for 30+ languages. To optimize the JavaScript bundle to include only your supported languages, install our bundler plugin. @@ -310,12 +273,9 @@ export const description = 'How to integrate with your framework.'; - To integrate with a client-only Rollup SPA, configure React Aria links to use your client side router, and optimize the client bundle to include localized strings for your supported languages. + To integrate with a client-only Rollup SPA, and optimize the client bundle to include localized strings for your supported languages. - - - By default, React Aria includes localized strings for 30+ languages. To optimize the JavaScript bundle to include only your supported languages, install our bundler plugin. @@ -340,12 +300,9 @@ export const description = 'How to integrate with your framework.'; - To integrate with a client-only ESBuild SPA, configure React Aria links to use your client side router, and optimize the client bundle to include localized strings for your supported languages. + To integrate with a client-only ESBuild SPA, and optimize the client bundle to include localized strings for your supported languages. - - - By default, React Aria includes localized strings for 30+ languages. To optimize the JavaScript bundle to include only your supported languages, install our bundler plugin. diff --git a/packages/dev/s2-docs/src/routers.mdx b/packages/dev/s2-docs/src/routers.mdx deleted file mode 100644 index 575effd9017..00000000000 --- a/packages/dev/s2-docs/src/routers.mdx +++ /dev/null @@ -1,78 +0,0 @@ -import {Counter} from './Step'; - -Render a `RouterProvider` at the root of your app to enable React Aria links to use your client side router. This accepts two props: - -1. `navigate` – a function received from your router for performing a client side navigation programmatically. -2. `useHref` (optional) – converts a router-specific href to a native HTML href, e.g. prepending a base path. - - - -```tsx -// src/app.tsx -import {RouterProvider} from 'react-aria-components'; -import {BrowserRouter, useNavigate, useHref, type NavigateOptions} from 'react-router'; - -/*- begin highlight -*/ -// Configure the type of the `routerOptions` prop on all React Aria components. -declare module 'react-aria-components' { - interface RouterConfig { - routerOptions: NavigateOptions - } -} -/*- end highlight -*/ - -function App() { - let navigate = useNavigate(); - - return ( - /*- begin highlight -*/ - - {/*- end highlight -*/} - {/* Your app here... */} - - } /> - {/* ... */} - - - ); -} -``` - -```tsx -// src/routes/__root.tsx -import {RouterProvider} from 'react-aria-components'; -import {useRouter, type NavigateOptions, type ToOptions} from '@tanstack/react-router'; - -/*- begin highlight -*/ -// Configure the type of the `href` and `routerOptions` props on all React Aria components. -declare module 'react-aria-components' { - interface RouterConfig { - href: ToOptions, - routerOptions: Omit - } -} -/*- end highlight -*/ - -export const Route = createRootRoute({ - component: () => { - let router = useRouter(); - return ( - /*- begin highlight -*/ - { - if (typeof href === "string") return; - return router.navigate({ ...href, ...opts }); - }} - useHref={(href) => { - if (typeof href === "string") return href; - return router.buildLocation(href).href; - }}> - {/*- end highlight -*/} - {/* Your app here... */} - - ); - } -}); -``` - - From 1c85bccad55ed8eda52d6532f5123ee791c3f4be Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 2 Feb 2026 14:51:37 -0800 Subject: [PATCH 7/7] docs: Fix formatting of prop descriptions with multiple paragraphs (#9574) * docs: Fix formatting of prop descriptions with multiple paragraphs * lint --------- Co-authored-by: Robert Snow --- packages/dev/s2-docs/src/PropTable.tsx | 2 +- packages/dev/s2-docs/src/types.tsx | 9 ++++++++- packages/react-aria-components/src/utils.tsx | 14 ++++++++------ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/dev/s2-docs/src/PropTable.tsx b/packages/dev/s2-docs/src/PropTable.tsx index 08783772e1a..25789d9ec4a 100644 --- a/packages/dev/s2-docs/src/PropTable.tsx +++ b/packages/dev/s2-docs/src/PropTable.tsx @@ -196,7 +196,7 @@ function Rows({props, showDefault, showRequired}: {props: TInterface['properties } {prop.description && - {renderHTMLfromMarkdown(prop.description, {forceInline: true})} + {renderHTMLfromMarkdown(prop.description, {forceInline: false, forceBlock: true})} } )); diff --git a/packages/dev/s2-docs/src/types.tsx b/packages/dev/s2-docs/src/types.tsx index 48d30846ca7..292a05aea69 100644 --- a/packages/dev/s2-docs/src/types.tsx +++ b/packages/dev/s2-docs/src/types.tsx @@ -479,9 +479,16 @@ export function TypeLink({type}: {type: Extract

}, + ul: {component: (props) =>