diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 89999c3d313..d1dbc0b0558 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -624,8 +624,7 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps {ctx => ( diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index cdb2a1ecf04..6dd2ef7b926 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -215,7 +215,7 @@ export class S2TableLayout extends TableLayout { // we want the body to be sticky and only as wide as the table so it is always in view if loading/empty let isEmptyOrLoading = this.virtualizer?.collection.size === 0; if (isEmptyOrLoading) { - layoutInfo.rect.width = this.virtualizer!.visibleRect.width - 80; + layoutInfo.rect.width = this.virtualizer!.size.width - 80; } return [ @@ -228,7 +228,7 @@ export class S2TableLayout extends TableLayout { let layoutNode = super.buildLoader(node, x, y); let {layoutInfo} = layoutNode; layoutInfo.allowOverflow = true; - layoutInfo.rect.width = this.virtualizer!.visibleRect.width; + layoutInfo.rect.width = this.virtualizer!.size.width; // If performing first load or empty, the body will be sticky so we don't want to apply sticky to the loader, otherwise it will // affect the positioning of the empty state renderer let collection = this.virtualizer!.collection; @@ -246,7 +246,7 @@ export class S2TableLayout extends TableLayout { // If loading or empty, we'll want the body to be sticky and centered let isEmptyOrLoading = this.virtualizer?.collection.size === 0; if (isEmptyOrLoading) { - layoutInfo.rect = new Rect(40, 40, this.virtualizer!.visibleRect.width - 80, this.virtualizer!.visibleRect.height - 80); + layoutInfo.rect = new Rect(40, 40, this.virtualizer!.size.width - 80, this.virtualizer!.size.height - 80); layoutInfo.isSticky = true; } diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 45ce91272e3..f37a8976606 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -49,9 +49,8 @@ import User from '../s2wf-icons/S2_Icon_User_20_N.svg'; import {useTreeData} from 'react-stately/useTreeData'; let onActionFunc = action('onAction'); -let noOnAction = null; +let noOnAction = undefined; const onActionOptions = {onActionFunc, noOnAction}; - const events = ['onResizeStart', 'onResize', 'onResizeEnd', 'onSelectionChange', 'onSortChange']; const meta: Meta = { @@ -63,7 +62,7 @@ const meta: Meta = { tags: ['autodocs'], args: {...getActionArgs(events)}, argTypes: { - ...categorizeArgTypes('Events', ['onAction', 'onLoadMore', 'onResizeStart', 'onResize', 'onResizeEnd', 'onSelectionChange', 'onSortChange']), + ...categorizeArgTypes('Events', ['onAction', 'onLoadMore', ...events]), children: {table: {disable: true}}, onAction: { options: Object.keys(onActionOptions), // An array of serializable values @@ -1784,7 +1783,7 @@ export const TableWithNestedRows: StoryObj = { 5/22/1980 - + Applications Folder 4/7/2025 diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx index b9bebd80f52..d28392e2568 100644 --- a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -45,9 +45,9 @@ import {useAsyncList} from 'react-stately/useAsyncList'; import {useListData} from 'react-stately/useListData'; let onActionFunc = action('onAction'); -let noOnAction = null; +let noOnAction = undefined; const onActionOptions = {onActionFunc, noOnAction}; -const events = ['onSelectionChange', 'onAction']; +const events = ['onSelectionChange']; const meta: Meta = { component: TreeView, @@ -57,7 +57,7 @@ const meta: Meta = { tags: ['autodocs'], args: {...getActionArgs(events)}, argTypes: { - ...categorizeArgTypes('Events', events), + ...categorizeArgTypes('Events', ['onAction', ...events]), children: {table: {disable: true}}, onAction: { options: Object.keys(onActionOptions), // An array of serializable values @@ -81,7 +81,7 @@ const TreeExampleStatic = (args: TreeViewProps): ReactElement => (
diff --git a/packages/dev/codemods/src/s1-to-s2/README.md b/packages/dev/codemods/src/s1-to-s2/README.md index 0751b1c1a80..fbc71b472cb 100644 --- a/packages/dev/codemods/src/s1-to-s2/README.md +++ b/packages/dev/codemods/src/s1-to-s2/README.md @@ -9,6 +9,9 @@ Run `npx @react-spectrum/codemods s1-to-s2` from the directory you want to upgra ### Options - `-c, --components `: Comma separated list of components to upgrade (ex: `Button,TableView`). If not specified, all available components will be upgraded. +- `--path `: The path to the directory to run the codemod in. Defaults to the current directory (`.`). +- `-d, --dry`: Run the codemod without writing any changes to disk. Use this to preview migrations before applying. +- `--agent`: Run in non-interactive mode. Skips interactive prompts, package installation, and macro setup. Required when running in CI or from an agent tool. Note: `@react-spectrum/s2` must still be installed and resolvable. ## How it works diff --git a/packages/dev/codemods/src/s1-to-s2/UPGRADE.md b/packages/dev/codemods/src/s1-to-s2/UPGRADE.md index 185ec8886a9..360b97efd7c 100644 --- a/packages/dev/codemods/src/s1-to-s2/UPGRADE.md +++ b/packages/dev/codemods/src/s1-to-s2/UPGRADE.md @@ -265,7 +265,7 @@ Example: ### Border width -Affected style props: `borderWidth`, `borderStartWidth`, `borderEndWidth`, `borderTopWidth`, `orderBottomWidth`, `borderXWidth`, `borderYWidth`. +Affected style props: `borderWidth`, `borderStartWidth`, `borderEndWidth`, `borderTopWidth`, `borderBottomWidth`, `borderXWidth`, `borderYWidth`. Example: diff --git a/packages/dev/s2-docs/migration-references/focused-manual-fixes.md b/packages/dev/s2-docs/migration-references/focused-manual-fixes.md new file mode 100644 index 00000000000..d695eca7183 --- /dev/null +++ b/packages/dev/s2-docs/migration-references/focused-manual-fixes.md @@ -0,0 +1,143 @@ +# Manual fixes after the codemod + +## Icons and illustrations + +- If the codemod leaves `TODO(S2-upgrade)` next to an icon or illustration import, pick the nearest S2 replacement manually. + +## Layout components + +`Flex`, `Grid`, `View`, and `Well` are not part of S2. These should be updated to `div` elements styled with the macro. + +### Flex example + +Before: + +```jsx + +
Item 1
+
Item 2
+
Item 3
+
+``` + +After: + +```jsx +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +
+
Item 1
+
Item 2
+
Item 3
+
+``` + +### Grid example + +Before: + +```jsx + +
Item 1
+
Item 2
+
Item 3
+
+``` + +After: + +```jsx +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +
+
Item 1
+
Item 2
+
Item 3
+
+``` + +### View example + +Before: + +```jsx + + Content + +``` + +After: + +```jsx +
+ Content +
+``` + +### Well example + +Before: + +```jsx + + Content + +``` + +After: + +```jsx +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +
+ Content +
+``` + +## UNSAFE_style and UNSAFE_className + +Move `UNSAFE_style` usage to the S2 style macro when possible. + +Move `UNSAFE_className` usage to the S2 style macro when possible. + +Reference the S2 styling docs to see the supported CSS properties. + +## Dialogs + +- `DialogContainer` and `useDialogContainer` still exist in S2, but the dismiss logic may need to move between `Dialog`, `DialogTrigger`, and `DialogContainer`. See the S2 Dialog documentation for more details. + +## Collections + +- When `Item` survives the codemod, rename it based on its parent component: + + | Parent component | v3 child | S2 child | + |---|---|---| + | Menu / ActionMenu | Item | MenuItem | + | Picker | Item | PickerItem | + | ComboBox | Item | ComboBoxItem | + | Tabs | Item | Tab / TabPanel | + | TagGroup | Item | Tag | + | Breadcrumbs | Item | Breadcrumb | + +- Preserve React `key` when mapping arrays, but ensure collection data items expose `id` when S2 expects it. See the S2 Collections documentation for more details. +- Table and ListView migrations often need manual review for row headers, nested columns, and explicit item ids. + +## Toast migration + +- Move `ToastContainer` and `ToastQueue` imports from `@react-spectrum/toast` to `@react-spectrum/s2`. +- Keep a shared `ToastContainer` mounted near the app root or test harness, then update all queue calls to use the S2 import path. +- S2 supports `ToastQueue.neutral`, `positive`, `negative`, and `info`. +- Re-check options such as `timeout`, `actionLabel`, `onAction`, `shouldCloseOnAction`, and `onClose` after the import move. +- The queue methods still return a close function. Keep programmatic dismissal logic when the existing UX depends on it. +- Search for every `ToastContainer` mount and every `ToastQueue` usage after moving imports. Shared app roots, secondary entrypoints, and test harnesses are easy to miss. diff --git a/packages/dev/s2-docs/migration-references/focused-prerequisites.md b/packages/dev/s2-docs/migration-references/focused-prerequisites.md new file mode 100644 index 00000000000..5a31a1d0200 --- /dev/null +++ b/packages/dev/s2-docs/migration-references/focused-prerequisites.md @@ -0,0 +1,23 @@ +# Inspection checklist + +## Minimum tool versions + +These tools are not all strictly required, but if the project uses them they must be at these minimum versions to avoid issues with the `with {type: 'macro'}` import syntax: + +- **TypeScript 5.3+** — required for the import attributes syntax (`with {type: 'macro'}`). +- **Babel 7.27.0+** or the `@babel/plugin-syntax-import-attributes` plugin — enables Babel to parse import attributes. Alternatively, `@babel/preset-env` with `shippedProposals: true` also enables import attribute parsing. +- **ESLint 9.14.0+** with `@typescript-eslint/parser`. +- **Prettier 3.1.1+** — needed to format `with {type: 'macro'}` import syntax correctly. + +## What to look for + +- Search package manifests and source for `@adobe/react-spectrum`, `@react-spectrum/*`, and `@spectrum-icons/*`. +- In monorepos or mixed-tooling repos, inspect the target package or app first instead of assuming the root manifest represents the runtime target being migrated. +- Determine the package manager from the relevant lockfile or workspace setup. +- Detect the bundler at the migration target level. The workspace root may include Storybook, Vite, or other tooling that does not represent the runtime bundler for the package being migrated. + - **Parcel v2.12.0+** already supports S2 style macros natively. + - **Vite, webpack, Next.js, Rollup, ESBuild** and similar toolchains need `unplugin-parcel-macros`. Keep plugin ordering correct so macros run before the rest of the toolchain. + - If the repo already has a framework-specific S2 or macro setup, preserve it instead of layering a second macro configuration on top. +- Find **all** app entrypoints, including standalone pages, alternate render roots, embedded sub-apps, utility apps, and test-only render targets. Do not assume there is only one entry. +- Locate root providers, shared test wrappers, toast setup, and any direct `defaultTheme` usage. +- Search for `ToastContainer`, `ToastQueue`, `DialogContainer`, `useDialogContainer`, `ClearSlots`, style props, and `UNSAFE_style`. These are common follow-up areas after the codemod. diff --git a/packages/dev/s2-docs/package.json b/packages/dev/s2-docs/package.json index b4b78e88b0b..eb14a2a3041 100644 --- a/packages/dev/s2-docs/package.json +++ b/packages/dev/s2-docs/package.json @@ -49,6 +49,8 @@ "json5": "^2.2.3", "lz-string": "^1.5.0", "markdown-to-jsx": "^6.11.0", + "mdast-util-mdx": "^1.0.0", + "mdast-util-to-markdown": "^1.0.0", "react": "^19.2.0", "react-aria": "^3.40.0", "react-aria-components": "^1.7.1", diff --git a/packages/dev/s2-docs/pages/s2/CheckboxGroup.mdx b/packages/dev/s2-docs/pages/s2/CheckboxGroup.mdx index 1b83c178c00..16bf74a9a55 100644 --- a/packages/dev/s2-docs/pages/s2/CheckboxGroup.mdx +++ b/packages/dev/s2-docs/pages/s2/CheckboxGroup.mdx @@ -21,7 +21,7 @@ import {CheckboxGroup, Checkbox} from '@react-spectrum/s2/CheckboxGroup'; Soccer Baseball - Basketball + Synchronized Swimming ``` diff --git a/packages/dev/s2-docs/pages/s2/RadioGroup.mdx b/packages/dev/s2-docs/pages/s2/RadioGroup.mdx index da9c76a4375..c55070d0055 100644 --- a/packages/dev/s2-docs/pages/s2/RadioGroup.mdx +++ b/packages/dev/s2-docs/pages/s2/RadioGroup.mdx @@ -21,7 +21,7 @@ import {RadioGroup, Radio} from '@react-spectrum/s2/RadioGroup'; Cat Dog - Dragon + Leopard Gecko ``` diff --git a/packages/dev/s2-docs/pages/s2/migrating.mdx b/packages/dev/s2-docs/pages/s2/migrating.mdx index f5dfa069f46..60b56f423b7 100644 --- a/packages/dev/s2-docs/pages/s2/migrating.mdx +++ b/packages/dev/s2-docs/pages/s2/migrating.mdx @@ -2,6 +2,7 @@ import {Layout} from '../../src/Layout'; import {PendingBadge} from '../../src/PendingBadge'; import {InstallCommand} from '../../src/InstallCommand'; import {StaticTable} from '../../src/StaticTable'; +import {Command} from '../../src/Command'; export default Layout; export const section = 'Guides'; @@ -12,6 +13,18 @@ export const description = 'How to migrate from React Spectrum v3 to Spectrum 2. Learn how to migrate from React Spectrum v3 to Spectrum 2. +## AI-assisted migration (recommended) + +If you're using an AI coding tool that supports [Agent Skills](https://agentskills.io/home), React Spectrum now includes a dedicated skill for guiding v3 to S2 upgrades. + +To install the migration skill and the general S2 skill, run: + + + +Then ask your agent to use the `migrate-react-spectrum-v3-to-s2` skill to migrate your project. + +## Command-line tool + An automated upgrade assistant is available by running the following command in the project you want to upgrade: @@ -25,6 +38,7 @@ The following arguments are also available: - `--path` - Path to apply the upgrade changes to. Defaults to the current directory (`.`) - `--dry` - Runs the upgrade assistant without making changes to components - `--ignore-pattern` - Ignore files that match the provided glob expression. Defaults to `'**/node_modules/**'` +- `--agent` - Runs the upgrade assistant non-interactively for AI tools. This skips prompts, package installation, and macro setup, so `@react-spectrum/s2` must already be installed and resolvable For cases that the upgrade assistant doesn't handle automatically or where you'd rather upgrade some components manually, use the guide below. diff --git a/packages/dev/s2-docs/scripts/generateAgentSkills.mjs b/packages/dev/s2-docs/scripts/generateAgentSkills.mjs index e9e019140bf..aa7aecf2303 100644 --- a/packages/dev/s2-docs/scripts/generateAgentSkills.mjs +++ b/packages/dev/s2-docs/scripts/generateAgentSkills.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Generates Agent Skills for React Spectrum (S2) and React Aria. + * Generates Agent Skills for React Spectrum (S2), migration, and React Aria. * * This script creates skills in the Agent Skills format (https://agentskills.io/specification) * @@ -27,6 +27,7 @@ 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 MIGRATION_REFS_DIR = path.join(REPO_ROOT, 'packages/dev/s2-docs/migration-references'); const WELL_KNOWN_DIR = '.well-known'; const WELL_KNOWN_SKILLS_DIR = 'skills'; @@ -45,6 +46,20 @@ const SKILLS = { website: 'https://react-spectrum.adobe.com/' } }, + 'migrate-react-spectrum-v3-to-s2': { + name: 'migrate-react-spectrum-v3-to-s2', + description: + 'Upgrade React Spectrum v3 (Spectrum 1) codebases to React Spectrum S2. Use when developers mention migrating or upgrading from React Spectrum v3, Spectrum 1, S1, @adobe/react-spectrum, @react-spectrum/* packages, or codemod-assisted upgrades to @react-spectrum/s2.', + kind: 'migration', + license: 'Apache-2.0', + sourceDir: 's2', + compatibility: + 'Requires a React project currently using React Spectrum v3, @react-spectrum/* packages, or related React Spectrum v3 helpers.', + metadata: { + author: 'Adobe', + website: 'https://react-spectrum.adobe.com/' + } + }, 'react-aria': { name: 'react-aria', description: @@ -283,23 +298,25 @@ function categorizeEntries(entries, sourceDir) { 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} +function generateFrontmatter(skillConfig) { + return `--- +name: "${skillConfig.name}" +description: "${skillConfig.description}" +license: "${skillConfig.license}" +compatibility: "${skillConfig.compatibility}" metadata: - author: ${skillConfig.metadata.author} - website: ${skillConfig.metadata.website} + author: "${skillConfig.metadata.author}" + website: "${skillConfig.metadata.website}" --- `; +} - let content = frontmatter; +/** + * Generate the SKILL.md content + */ +function generateDocsSkillMd(skillConfig, categories, isS2) { + let content = generateFrontmatter(skillConfig); if (isS2) { content += `# React Spectrum S2 (Spectrum 2) @@ -386,10 +403,125 @@ The \`references/\` directory contains detailed documentation organized as follo return content.trimEnd() + '\n'; } +function generateMigrationSkillMd(skillConfig) { + return `${generateFrontmatter(skillConfig)}# React Spectrum v3 to S2 migration + +Upgrade React Spectrum v3 codebases to S2 by following these eight steps in order. + +## Scope + +This skill covers only the React Spectrum v3 (S1) to S2 migration. Do **not** perform major dependency upgrades such as React version bumps (e.g. React 16→17, 17→18, 18→19) as part of this migration. If the project needs a major dependency upgrade, note it as a recommended follow-up in the final report (Step 8) rather than attempting it during migration. + +## Step 1: Inspect the codebase + +- Search package manifests for \`@adobe/react-spectrum\`, \`@react-spectrum/*\`, and \`@spectrum-icons/*\`. +- Note the package manager (npm, yarn, pnpm) from the lockfile. +- Identify the bundler used by the migration target (Parcel, Vite, webpack, Next.js, Rollup, ESBuild). +- In monorepos, inspect the specific package or app being migrated rather than the workspace root. +- Find app entrypoints, root providers, shared test wrappers, toast setup, and any \`defaultTheme\` usage. + +See [Prerequisites](references/focused-prerequisites.md) for the full inspection checklist and minimum tool versions. + +## Step 2: Install @react-spectrum/s2 + +Install the S2 package with the project's package manager: + +\`\`\`bash +npm install @react-spectrum/s2 +yarn add @react-spectrum/s2 +pnpm add @react-spectrum/s2 +\`\`\` + +If the bundler is not Parcel v2.12.0+, also install and configure \`unplugin-parcel-macros\` as a dev dependency. See [Getting started](references/docs-getting-started.md) for bundler-specific setup instructions. + +## Step 3: Dry-run the codemod + +Preview what the codemod will change before applying: + +\`\`\`bash +npx @react-spectrum/codemods s1-to-s2 --agent --dry +yarn dlx @react-spectrum/codemods s1-to-s2 --agent --dry +pnpm dlx @react-spectrum/codemods s1-to-s2 --agent --dry +\`\`\` + +Use \`npx\` for npm/Yarn 1, \`yarn dlx\` for Yarn Berry/PnP, \`pnpm dlx\` for pnpm. +Add \`--path \` for monorepos or partial rollouts. +Add \`--components A,B\` only when explicitly requested for incremental migration. + +Review the dry-run output to understand the scope of changes. + +## Step 4: Run the codemod + +Execute the codemod to transform the source files: + +\`\`\`bash +npx @react-spectrum/codemods s1-to-s2 --agent +yarn dlx @react-spectrum/codemods s1-to-s2 --agent +pnpm dlx @react-spectrum/codemods s1-to-s2 --agent +\`\`\` + +Use the same \`--path\` and \`--components\` flags as the dry run if applicable. + +## Step 5: Format with the project's formatter + +If the project has a formatter (Prettier, ESLint, Biome, Oxfmt, etc.), run it on the changed files to remove extraneous formatting changes introduced by the codemod. + +## Step 6: Fix remaining TODO(S2-upgrade) comments + +Search the codebase for \`TODO(S2-upgrade)\` comments left by the codemod. Each one marks a change that requires manual review. + +See [Focused manual fixes](references/focused-manual-fixes.md) for information on how to fix these. + +Also reference the \`react-spectrum-s2\` skill (if available) for full S2 component documentation when needed. + +## Step 7: Validate + +Run the project's own toolchain to verify the migration is complete: + +1. Install dependencies if package manifests changed. +2. Run the typecheck or compile step (e.g. \`tsc --noEmit\`, \`tsc -b\`). +3. Run tests covering the migrated code. Prefer the narrowest test scope that covers the changed files. +4. Run the build to confirm the output is intact. + +In monorepos, validate the affected package first with its own scripts before running workspace-wide checks. Fix any failures before declaring the migration complete. + +## Step 8: Generate final report + +After the migration is complete, produce a final report for the user with the following sections: + +### Summary of changes +- Packages added and removed. +- What the codemod changed (files affected, components migrated). +- Manual fixes applied (layout components, icons, dialogs, collections, toast, etc.). + +### Remaining issues +- Any unresolved \`TODO(S2-upgrade)\` comments. +- Type errors, test failures, or known gaps that still need attention. + +### Recommended follow-ups +- If the project is not on **React 19**, recommend upgrading. React 19 is recommended for S2. Include the relevant upgrade guide links: + - React 17: https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html + - React 18: https://react.dev/blog/2022/03/08/react-18-upgrade-guide + - React 19: https://react.dev/blog/2024/04/25/react-19-upgrade-guide +- Any other major upgrades (e.g. React, bundler, etc.) that were out of scope for this migration. +- Any additional cleanup or improvements the user may want to address. + +## Deep reference + +Use these when you need more component-by-component or API-level detail: +- [Migration guide](references/docs-migrating.md): comprehensive component-by-component migration reference. +- [Getting started](references/docs-getting-started.md): framework setup and macro configuration. +- [Provider](references/docs-provider.md): locale, router, color-scheme, and SSR usage. +- [Styling](references/docs-styling.md): style macro overview including runtime conditions, CSS variables, CSS optimization, and CSS resets. +- [Style macro](references/docs-style-macro.md): exact style macro syntax and constraints. +- [Toast](references/docs-toast.md): full S2 toast API and examples. +`.trimEnd() + '\n'; +} + /** * Copy documentation files to the skill's references directory */ -function copyDocumentation(skillConfig, categories, skillDir) { +function copyDocsDocumentation(skillConfig, categories, skillDir) { const refsDir = path.join(skillDir, 'references'); const sourceDir = path.join(MARKDOWN_DOCS_DIST, skillConfig.sourceDir); @@ -463,6 +595,49 @@ function copyDocumentation(skillConfig, categories, skillDir) { } } +function copyFocusedDocs(sourceDir, skillDir, docs) { + for (const [sourceName, outputName] of docs) { + const sourcePath = path.join(MARKDOWN_DOCS_DIST, sourceDir, sourceName); + if (!fs.existsSync(sourcePath)) { + console.warn(`Warning: expected migration reference not found: ${sourcePath}`); + continue; + } + + const outputPath = path.join(skillDir, 'references', outputName); + fs.mkdirSync(path.dirname(outputPath), {recursive: true}); + fs.copyFileSync(sourcePath, outputPath); + } +} + +function writeMigrationReferences(skillDir, sourceDir) { + // Copy focused reference docs from source files + const focusedRefs = [ + 'focused-prerequisites.md', + 'focused-manual-fixes.md' + ]; + + for (const filename of focusedRefs) { + const sourcePath = path.join(MIGRATION_REFS_DIR, filename); + if (!fs.existsSync(sourcePath)) { + console.warn(`Warning: expected migration reference not found: ${sourcePath}`); + continue; + } + + const outputPath = path.join(skillDir, 'references', filename); + fs.mkdirSync(path.dirname(outputPath), {recursive: true}); + fs.copyFileSync(sourcePath, outputPath); + } + + copyFocusedDocs(sourceDir, skillDir, [ + ['migrating.md', 'docs-migrating.md'], + ['getting-started.md', 'docs-getting-started.md'], + ['Provider.md', 'docs-provider.md'], + ['styling.md', 'docs-styling.md'], + ['style-macro.md', 'docs-style-macro.md'], + ['Toast.md', 'docs-toast.md'] + ]); +} + function collectSkillFiles(skillDir) { const files = []; @@ -494,6 +669,37 @@ function collectSkillFiles(skillDir) { }); } +/** + * Validate that all references/ links in SKILL.md resolve to actual files. + * Throws if any broken links are found. + */ +function validateSkillLinks(skillDir) { + const skillMdPath = path.join(skillDir, 'SKILL.md'); + if (!fs.existsSync(skillMdPath)) { + return; + } + + const content = fs.readFileSync(skillMdPath, 'utf8'); + const linkPattern = /\[([^\]]*)\]\((references\/[^)]+)\)/g; + const broken = []; + + let match; + while ((match = linkPattern.exec(content)) !== null) { + const linkText = match[1]; + const linkPath = match[2]; + const resolvedPath = path.join(skillDir, linkPath); + if (!fs.existsSync(resolvedPath)) { + broken.push(`"${linkText}" -> ${linkPath}`); + } + } + + if (broken.length > 0) { + throw new Error( + `Broken references in ${path.relative(REPO_ROOT, skillMdPath)}:\n ${broken.join('\n ')}` + ); + } +} + function writeIndexJson(wellKnownRoot, skills) { const indexPath = path.join(wellKnownRoot, 'index.json'); const payload = {skills}; @@ -506,11 +712,27 @@ function writeIndexJson(wellKnownRoot, skills) { */ 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}); + if (skillConfig.kind === 'migration') { + const skillMdContent = generateMigrationSkillMd(skillConfig); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMdContent); + console.log( + `Generated ${path.relative(REPO_ROOT, path.join(skillDir, 'SKILL.md'))}` + ); + + writeMigrationReferences(skillDir, skillConfig.sourceDir); + console.log( + `Copied migration references to ${path.relative(REPO_ROOT, path.join(skillDir, 'references'))}` + ); + + return skillDir; + } + + const isS2 = skillConfig.name === 'react-spectrum-s2'; + // Parse documentation entries const llmsTxtPath = path.join( MARKDOWN_DOCS_DIST, @@ -526,14 +748,14 @@ function generateSkill(skillConfig, wellKnownRoot) { const categories = categorizeEntries(entries, skillConfig.sourceDir); // Generate SKILL.md - const skillMdContent = generateSkillMd(skillConfig, categories, isS2); + const skillMdContent = generateDocsSkillMd(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); + copyDocsDocumentation(skillConfig, categories, skillDir); console.log( `Copied documentation to ${path.relative(REPO_ROOT, path.join(skillDir, 'references'))}` ); @@ -542,7 +764,7 @@ function generateSkill(skillConfig, wellKnownRoot) { } -async function main() { +function main() { console.log( 'Generating Agent Skills for React Spectrum (S2) and React Aria...\n' ); @@ -569,12 +791,17 @@ async function main() { for (const config of skills) { console.log(`\nGenerating skill: ${config.name}`); const skillDir = generateSkill(config, wellKnownRoot); + validateSkillLinks(skillDir); const files = collectSkillFiles(skillDir); - indexEntries.push({ + const entry = { name: config.name, description: config.description, files - }); + }; + if (config.kind) { + entry.kind = config.kind; + } + indexEntries.push(entry); } writeIndexJson(wellKnownRoot, indexEntries); @@ -586,7 +813,9 @@ async function main() { console.log('\nAgent Skills generation complete!'); } -main().catch((err) => { +try { + main(); +} catch (err) { console.error(err); process.exit(1); -}); +} diff --git a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs index 4ade0b35756..2fcc0a6f679 100644 --- a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs +++ b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs @@ -4,11 +4,12 @@ import * as babel from '@babel/parser'; import {fileURLToPath} from 'url'; import fs from 'fs'; import glob from 'fast-glob'; +import {mdxToMarkdown} from 'mdast-util-mdx'; import path from 'path'; import {Project} from 'ts-morph'; import remarkMdx from 'remark-mdx'; import remarkParse from 'remark-parse'; -import remarkStringify from 'remark-stringify'; +import {toMarkdown} from 'mdast-util-to-markdown'; import {unified} from 'unified'; import {visit} from 'unist-util-visit'; @@ -3222,14 +3223,17 @@ async function main() { .use(remarkParse) .use(remarkMdx) .use(remarkRemoveImportsExports) - .use(remarkDocsComponentsToMarkdown) - .use(remarkStringify, { - fences: true, - bullets: '-', - listItemIndent: 'one' - }); - - let markdown = String(await processor.process({value: mdContent, path: filePath})); + .use(remarkDocsComponentsToMarkdown); + + const file = {value: mdContent, path: filePath}; + const tree = processor.parse(file); + const transformed = await processor.run(tree, file); + let markdown = toMarkdown(transformed, { + fences: true, + bullet: '-', + listItemIndent: 'one', + extensions: mdxToMarkdown.extensions + }); // Convert markdown links ending in .html to .md (relative links only) markdown = markdown.replace(/\[([^\]]+)\]\(([^)]+\.html)\)/g, (match, text, url) => { diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 9dc9bb82c51..d6ef3c99a87 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -68,7 +68,12 @@ export interface ComboBoxRenderProps { * Whether the combobox is required. * @selector [data-required] */ - isRequired: boolean + isRequired: boolean, + /** + * Whether the combobox is read only. + * @selector [data-readonly] + */ + isReadOnly: boolean } export interface ComboBoxProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps, GlobalDOMAttributes { @@ -97,7 +102,7 @@ export const ComboBoxStateContext = createContext(props: ComboBoxProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, ComboBoxContext); - let {children, isDisabled = false, isInvalid = false, isRequired = false} = props; + let {children, isDisabled = false, isInvalid = false, isRequired = false, isReadOnly = false} = props; let content = useMemo(() => ( {typeof children === 'function' @@ -106,11 +111,12 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co isDisabled, isInvalid, isRequired, - defaultChildren: null + defaultChildren: null, + isReadOnly }) : children} - ), [children, isDisabled, isInvalid, isRequired, props.items, props.defaultItems]); + ), [children, isDisabled, isInvalid, isRequired, isReadOnly, props.items, props.defaultItems]); return ( @@ -200,8 +206,9 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: isOpen: state.isOpen, isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid || false, - isRequired: props.isRequired || false - }), [state.isOpen, props.isDisabled, validation.isInvalid, props.isRequired]); + isRequired: props.isRequired || false, + isReadOnly: props.isReadOnly || false + }), [state.isOpen, props.isDisabled, validation.isInvalid, props.isRequired, props.isReadOnly]); let renderProps = useRenderProps({ ...props, @@ -262,6 +269,7 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: data-focused={state.isFocused || undefined} data-open={state.isOpen || undefined} data-disabled={props.isDisabled || undefined} + data-readonly={props.isReadOnly || undefined} data-invalid={validation.isInvalid || undefined} data-required={props.isRequired || undefined}> {renderProps.children} diff --git a/packages/react-aria-components/src/NumberField.tsx b/packages/react-aria-components/src/NumberField.tsx index e799d970fad..73b6ffd8812 100644 --- a/packages/react-aria-components/src/NumberField.tsx +++ b/packages/react-aria-components/src/NumberField.tsx @@ -50,6 +50,11 @@ export interface NumberFieldRenderProps { * @selector [data-invalid] */ isInvalid: boolean, + /** + * Whether the number field is read only. + * @selector [data-readonly] + */ + isReadOnly: boolean, /** * Whether the number field is required. * @selector [data-required] @@ -111,7 +116,8 @@ export const NumberField = /*#__PURE__*/ (forwardRef as forwardRefType)(function state, isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid || false, - isRequired: props.isRequired || false + isRequired: props.isRequired || false, + isReadOnly: props.isReadOnly || false }, defaultClassName: 'react-aria-NumberField' }); @@ -146,6 +152,7 @@ export const NumberField = /*#__PURE__*/ (forwardRef as forwardRefType)(function ref={ref} slot={props.slot || undefined} data-disabled={props.isDisabled || undefined} + data-readonly={props.isReadOnly || undefined} data-required={props.isRequired || undefined} data-invalid={validation.isInvalid || undefined} /> {props.name && } diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 6d7179d6cd4..cfd6413d281 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1377,6 +1377,7 @@ export const Row = /*#__PURE__*/ createBranchComponent( isFocusVisible: isFocusVisibleWithin, focusProps: focusWithinProps } = useFocusRing({within: true}); + let {hoverProps, isHovered} = useHover({ isDisabled: !states.allowsSelection && !states.hasAction, onHoverStart: props.onHoverStart, diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 4410d8785fe..262daaa0c90 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -75,6 +75,7 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat let {layout, layoutOptions} = useContext(LayoutContext)!; let layoutOptions2 = layout.useLayoutOptions?.(); let state = useVirtualizerState({ + allowsWindowScrolling: true, layout, collection, renderView: (type, item) => { @@ -98,9 +99,11 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat let {contentProps} = useScrollView({ onVisibleRectChange: state.setVisibleRect, + onSizeChange: state.setSize, contentSize: state.contentSize, onScrollStart: state.startScrolling, - onScrollEnd: state.endScrolling + onScrollEnd: state.endScrolling, + allowsWindowScrolling: true }, scrollRef!); return ( diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index 8899d732855..af75cba5644 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -937,4 +937,16 @@ describe('ComboBox', () => { expect(comboboxTester.combobox).toHaveFocus(); expect(onOpenChange).toHaveBeenCalledTimes(1); }); + + it('should support read-only state', async () => { + let {getByRole, rerender} = render( + + ); + + let input = getByRole('combobox'); + + expect(input.closest('.react-aria-ComboBox')).not.toHaveAttribute('data-readonly'); + rerender(); + expect(input.closest('.react-aria-ComboBox')).toHaveAttribute('data-readonly'); + }); }); diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 7dc4c0e6d01..b2099a006db 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -120,6 +120,18 @@ describe('NumberField', () => { expect(group).not.toHaveClass('focus'); }); + it('should support read-only state', async () => { + let {getByRole, rerender} = render( + + ); + + let input = getByRole('textbox'); + + expect(input.closest('.react-aria-NumberField')).not.toHaveAttribute('data-readonly'); + rerender(); + expect(input.closest('.react-aria-NumberField')).toHaveAttribute('data-readonly'); + }); + it('should support render props', () => { let {getByRole} = render( diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 30eb7c111ca..0baa37e0aef 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -751,6 +751,36 @@ describe('Tree', () => { expect(onSelectionChange).toHaveBeenCalledTimes(0); }); + it('multi select should expand the row if anywhere on the row is clicked and there is no onAction provided', async () => { + let {getAllByRole} = render(); + let row = getAllByRole('row')[1]; + await user.hover(row); + expect(row).toHaveAttribute('data-hovered', 'true'); + + await user.click(row); + expect(row).toHaveAttribute('aria-expanded', 'true'); + }); + + it('single select should expand the row if anywhere on the row is clicked and there is no onAction provided', async () => { + let {getAllByRole} = render(); + let row = getAllByRole('row')[1]; + await user.hover(row); + expect(row).toHaveAttribute('data-hovered', 'true'); + + await user.click(row); + expect(row).toHaveAttribute('aria-expanded', 'true'); + }); + + it('no selection should expand the row if anywhere on the row is clicked and there is no onAction provided', async () => { + let {getAllByRole} = render(); + let row = getAllByRole('row')[1]; + await user.hover(row); + expect(row).toHaveAttribute('data-hovered', 'true'); + + await user.click(row); + expect(row).toHaveAttribute('aria-expanded', 'true'); + }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { let {getAllByRole} = render(); diff --git a/packages/react-aria-components/test/Treeble.test.js b/packages/react-aria-components/test/Treeble.test.tsx similarity index 93% rename from packages/react-aria-components/test/Treeble.test.js rename to packages/react-aria-components/test/Treeble.test.tsx index 404e2f6847c..bd87825ea02 100644 --- a/packages/react-aria-components/test/Treeble.test.js +++ b/packages/react-aria-components/test/Treeble.test.tsx @@ -98,8 +98,15 @@ function Example(props) { ); } +interface ReorderableTreebleItem { + id: string, + title: string, + type: string, + date: string, + children?: ReorderableTreebleItem[] +} function ReorderableTreeble(props) { - let tree = useTreeData({ + let tree = useTreeData({ initialItems: [ {id: '1', title: 'Documents', type: 'Directory', date: '10/20/2025', children: [ {id: '2', title: 'Project', type: 'Directory', date: '8/2/2025', children: [ @@ -114,7 +121,7 @@ function ReorderableTreeble(props) { ] }); - let {dragAndDropHooks} = useDragAndDrop({ + let {dragAndDropHooks} = useDragAndDrop<{value: ReorderableTreebleItem}>({ getItems: (keys, items) => items.map(item => ({'text/plain': item.value.title})), onMove(e) { if (e.target.dropPosition === 'before') { @@ -239,7 +246,7 @@ describe('Treeble', () => { expect(tester.rowHeaders[3]).toHaveTextContent('Job Posting'); }); - it.each(['mouse', 'touch', 'keyboard'])('should expand a row with %s', async (interactionType) => { + it.each(['mouse', 'touch', 'keyboard'] as const)('should expand a row with %s', async (interactionType) => { let tree = render(); let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); @@ -529,6 +536,39 @@ describe('Treeble', () => { expect(onSelectionChange).toHaveBeenLastCalledWith(new Set(['games', 'mario', 'tetris'])); }); + it('supports expansion on disabled items with no action in disabledBehavior="selection" multiple selection', async () => { + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + await user.hover(tester.rows[1]); + expect(tester.rows[1]).toHaveAttribute('data-hovered', 'true'); + + await user.click(tester.rows[1]); + expect(tester.rows[1]).toHaveAttribute('aria-expanded', 'true'); + }); + + it('supports expansion on disabled items with no action in disabledBehavior="selection" single selection', async () => { + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + await user.hover(tester.rows[1]); + expect(tester.rows[1]).toHaveAttribute('data-hovered', 'true'); + + await user.click(tester.rows[1]); + expect(tester.rows[1]).toHaveAttribute('aria-expanded', 'true'); + }); + + it('supports expansion on disabled items with no action in disabledBehavior="selection" no selection', async () => { + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + await user.hover(tester.rows[1]); + expect(tester.rows[1]).toHaveAttribute('data-hovered', 'true'); + + await user.click(tester.rows[1]); + expect(tester.rows[1]).toHaveAttribute('aria-expanded', 'true'); + }); + it('should support drag and drop', async () => { let tree = render(); let tester = utils.createTester('Table', {root: tree.getByRole('treegrid')}); diff --git a/packages/react-aria/src/grid/useGridRow.ts b/packages/react-aria/src/grid/useGridRow.ts index 1a66e8d0d7e..2489d76f5c7 100644 --- a/packages/react-aria/src/grid/useGridRow.ts +++ b/packages/react-aria/src/grid/useGridRow.ts @@ -12,7 +12,7 @@ import {chain} from '../utils/chain'; -import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; +import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; import {IGridCollection as GridCollection, GridNode} from 'react-stately/private/grid/GridCollection'; import {gridMap} from './utils'; import {GridState} from 'react-stately/private/grid/useGridState'; @@ -55,6 +55,34 @@ export function useGridRow, S extends GridState actions.onRowAction?.(node.key) : onAction; + + // Mirror useGridListItem: when no row action is provided, expandable tree-table rows use toggle as the + // primary action if selection is off or the row is selection-disabled (disabledKeys / selection behavior). + if ( + node != null && + 'treeColumn' in state && + state.treeColumn != null && + // I'd prefer if this was up in useTableRow, but onAction is a deprecated prop + // and maybe we'll move the expandable rows down into useGridRow eventually + 'toggleKey' in state && + typeof state.toggleKey === 'function' && + actions.onRowAction == null && + onAction == null + ) { + // adds the toggleKey type so it's not unknown below + let tableState = state as typeof state & {toggleKey: (key: Key) => void}; + let children = tableState.collection.getChildren?.(node.key); + let hasChildRows = [...(children ?? [])].length > 1; + let hasLink = state.selectionManager.isLink(node.key); + if ( + !hasLink && + hasChildRows && + ((state.disabledKeys.has(node.key) || node.props?.isDisabled) || + state.selectionManager.selectionMode === 'none')) { + onRowAction = () => tableState.toggleKey(node.key); + } + } + let {itemProps, ...states} = useSelectableItem({ selectionManager: state.selectionManager, key: node.key, diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index c2ab564852c..844dbaafbd2 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -102,7 +102,12 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt let children = state.collection.getChildren?.(node.key); hasChildRows = hasChildRows || [...(children ?? [])].length > 1; - if (onAction == null && !hasLink && state.selectionManager.selectionMode === 'none' && hasChildRows) { + if ( + onAction == null && + !hasLink && + hasChildRows && + ((state.disabledKeys.has(node.key) || node.props?.isDisabled) || + state.selectionManager.selectionMode === 'none')) { onAction = () => state.toggleKey(node.key); } diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx index 975858679d8..462bbec1f8e 100644 --- a/packages/react-aria/src/virtualizer/ScrollView.tsx +++ b/packages/react-aria/src/virtualizer/ScrollView.tsx @@ -35,12 +35,14 @@ import {useResizeObserver} from '../utils/useResizeObserver'; interface ScrollViewProps extends Omit, 'onScroll'> { contentSize: Size, onVisibleRectChange: (rect: Rect) => void, + onSizeChange?: (size: Size) => void, children?: ReactNode, innerStyle?: CSSProperties, onScrollStart?: () => void, onScrollEnd?: () => void, scrollDirection?: 'horizontal' | 'vertical' | 'both', - onScroll?: (e: Event) => void + onScroll?: (e: Event) => void, + allowsWindowScrolling?: boolean } function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { @@ -71,11 +73,13 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { updateVisibleRect(); + onSizeChange?.(state.size); }); // If the clientWidth or clientHeight changed, scrollbars appeared or disappeared as @@ -243,12 +250,13 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { updateVisibleRect(); + onSizeChange?.(state.size); }); } } isUpdatingSize.current = false; - }, [ref, state, updateVisibleRect]); + }, [ref, state, updateVisibleRect, onSizeChange]); let updateSizeEvent = useEffectEvent(updateSize); // Track the size of the entire window viewport, which is used to bound the size of the virtualizer's visible rectangle. diff --git a/packages/react-stately/src/layout/GridLayout.ts b/packages/react-stately/src/layout/GridLayout.ts index 6d06cf891b4..be2406c7238 100644 --- a/packages/react-stately/src/layout/GridLayout.ts +++ b/packages/react-stately/src/layout/GridLayout.ts @@ -112,22 +112,22 @@ export class GridLayout exte } = invalidationContext.layoutOptions || {}; this.dropIndicatorThickness = dropIndicatorThickness; - let visibleWidth = this.virtualizer!.visibleRect.width; + let virtualizerWidth = this.virtualizer!.size.width; // The max item width is always the entire viewport. // If the max item height is infinity, scale in proportion to the max width. - let maxItemWidth = Math.min(maxItemSize.width, visibleWidth); + let maxItemWidth = Math.min(maxItemSize.width, virtualizerWidth); let maxItemHeight = Number.isFinite(maxItemSize.height) ? maxItemSize.height : Math.floor((minItemSize.height / minItemSize.width) * maxItemWidth); // Compute the number of rows and columns needed to display the content - let columns = Math.floor(visibleWidth / (minItemSize.width + minSpace.width)); + let columns = Math.floor(virtualizerWidth / (minItemSize.width + minSpace.width)); let numColumns = Math.max(1, Math.min(maxColumns, columns)); this.numColumns = numColumns; // Compute the available width (minus the space between items) - let width = visibleWidth - (minSpace.width * Math.max(0, numColumns)); + let width = virtualizerWidth - (minSpace.width * Math.max(0, numColumns)); // Compute the item width based on the space available let itemWidth = Math.floor(width / numColumns); @@ -139,9 +139,9 @@ export class GridLayout exte itemHeight = Math.max(minItemSize.height, Math.min(maxItemHeight, itemHeight)); // Compute the horizontal spacing, content height and horizontal margin - let horizontalSpacing = Math.min(Math.max(maxHorizontalSpace, minSpace.width), Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1))); + let horizontalSpacing = Math.min(Math.max(maxHorizontalSpace, minSpace.width), Math.floor((virtualizerWidth - numColumns * itemWidth) / (numColumns + 1))); this.gap = new Size(horizontalSpacing, minSpace.height); - this.margin = Math.floor((visibleWidth - numColumns * itemWidth - horizontalSpacing * (numColumns + 1)) / 2); + this.margin = Math.floor((virtualizerWidth - numColumns * itemWidth - horizontalSpacing * (numColumns + 1)) / 2); // If there is a skeleton loader within the last 2 items in the collection, increment the collection size // so that an additional row is added for the skeletons. @@ -214,7 +214,7 @@ export class GridLayout exte y += maxHeight + minSpace.height; // Keep adding skeleton rows until we fill the viewport - if (skeleton && row === rows - 1 && y < this.virtualizer!.visibleRect.height) { + if (skeleton && row === rows - 1 && y < this.virtualizer!.size.height) { rows++; } } @@ -225,7 +225,7 @@ export class GridLayout exte if (skeletonCount > 0 || !lastNode.props.isLoading) { loaderHeight = 0; } - const loaderWidth = visibleWidth - horizontalSpacing * 2; + const loaderWidth = virtualizerWidth - horizontalSpacing * 2; // Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve // room for the loader alongside rendering the emptyState let rect = new Rect(horizontalSpacing, y, loaderWidth, loaderHeight); @@ -235,7 +235,7 @@ export class GridLayout exte } this.layoutInfos = newLayoutInfos; - this.contentSize = new Size(this.virtualizer!.visibleRect.width, y); + this.contentSize = new Size(this.virtualizer!.size.width, y); } getLayoutInfo(key: Key): LayoutInfo | null { diff --git a/packages/react-stately/src/layout/ListLayout.ts b/packages/react-stately/src/layout/ListLayout.ts index 9f451f6084c..31a2416651e 100644 --- a/packages/react-stately/src/layout/ListLayout.ts +++ b/packages/react-stately/src/layout/ListLayout.ts @@ -376,7 +376,7 @@ export class ListLayout exte offset = Math.max(offset - this.gap, 0); offset += isEmptyOrLoading ? 0 : this.padding; - this.contentSize = this.orientation === 'horizontal' ? new Size(offset, this.virtualizer!.visibleRect.height) : new Size(this.virtualizer!.visibleRect.width, offset); + this.contentSize = this.orientation === 'horizontal' ? new Size(offset, this.virtualizer!.size.height) : new Size(this.virtualizer!.size.width, offset); return nodes; } @@ -445,8 +445,8 @@ export class ListLayout exte protected buildSection(node: Node, x: number, y: number): LayoutNode { let collection = this.virtualizer!.collection; - let width = this.virtualizer!.visibleRect.width - this.padding - x; - let height = this.virtualizer!.visibleRect.height - this.padding - y; + let width = this.virtualizer!.size.width - this.padding - x; + let height = this.virtualizer!.size.height - this.padding - y; let rect = this.orientation === 'horizontal' ? new Rect(x, y, 0, height) : new Rect(x, y, width, 0); let layoutInfo = new LayoutInfo(node.type, node.key, rect); @@ -497,7 +497,7 @@ export class ListLayout exte protected buildSectionHeader(node: Node, x: number, y: number): LayoutNode { let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; - let width = this.virtualizer!.visibleRect[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); + let width = this.virtualizer!.size[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); let rectHeight = this.headingSize; let isEstimated = false; @@ -538,7 +538,7 @@ export class ListLayout exte let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; - let width = this.virtualizer!.visibleRect[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); + let width = this.virtualizer!.size[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); let rectHeight = this.rowSize; let isEstimated = false; diff --git a/packages/react-stately/src/layout/TableLayout.ts b/packages/react-stately/src/layout/TableLayout.ts index f6b72220116..a9fc643b8ad 100644 --- a/packages/react-stately/src/layout/TableLayout.ts +++ b/packages/react-stately/src/layout/TableLayout.ts @@ -124,7 +124,7 @@ export class TableLayout exten } } else if (invalidationContext.sizeChanged || this.columnsChanged(newCollection, this.lastCollection)) { let columnLayout = new TableColumnLayout({}); - this.columnWidths = columnLayout.buildColumnWidths(this.virtualizer!.visibleRect.width - this.padding * 2, newCollection, new Map()); + this.columnWidths = columnLayout.buildColumnWidths(this.virtualizer!.size.width - this.padding * 2, newCollection, new Map()); invalidationContext.sizeChanged = true; } @@ -345,7 +345,7 @@ export class TableLayout exten // Make sure that the table body gets a height if empty or performing initial load let isEmptyOrLoading = collection?.size === 0; if (isEmptyOrLoading) { - y = this.virtualizer!.visibleRect.maxY; + y = this.virtualizer!.size.height; } else { y -= this.gap; } diff --git a/packages/react-stately/src/layout/WaterfallLayout.ts b/packages/react-stately/src/layout/WaterfallLayout.ts index d1f98b56e7a..f6a389f6592 100644 --- a/packages/react-stately/src/layout/WaterfallLayout.ts +++ b/packages/react-stately/src/layout/WaterfallLayout.ts @@ -107,21 +107,21 @@ export class WaterfallLayout h !== startingHeights[i]) || - Math.min(...columnHeights) < this.virtualizer!.visibleRect.height + Math.min(...columnHeights) < this.virtualizer!.size.height ) { let key = `${node.key}-${skeletonCount++}`; let content = this.layoutInfos.get(key)?.content || {...node}; @@ -200,7 +200,7 @@ export class WaterfallLayout 0 || !lastNode.props.isLoading) { loaderHeight = 0; } - const loaderWidth = visibleWidth - horizontalSpacing * 2; + const loaderWidth = virtualizerWidth - horizontalSpacing * 2; // Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve // room for the loader alongside rendering the emptyState let rect = new Rect(horizontalSpacing, maxHeight, loaderWidth, loaderHeight); @@ -209,7 +209,7 @@ export class WaterfallLayout { readonly contentSize: Size; /** The currently visible rectangle. */ readonly visibleRect: Rect; + /** The size of the virtualizer scroll view. */ + readonly size: Size; /** The set of persisted keys that are always present in the DOM, even if not currently in view. */ readonly persistedKeys: Set; @@ -74,6 +76,7 @@ export class Virtualizer { this.layout = options.layout; this.contentSize = new Size; this.visibleRect = new Rect; + this.size = new Size; this.persistedKeys = new Set(); this._visibleViews = new Map(); this._renderedContent = new WeakMap(); @@ -288,19 +291,25 @@ export class Virtualizer { needsUpdate = true; } - if (!this.visibleRect.equals(opts.visibleRect)) { + if (!this.visibleRect.equals(opts.visibleRect) || !this.size.equals(opts.size)) { this._overscanManager.setVisibleRect(opts.visibleRect); - let shouldInvalidate = this.layout.shouldInvalidate(opts.visibleRect, this.visibleRect); + + // Create a rectangle using the scroll position and layout size of the scroll view. This is not the same + // as the visibleRect, whose width and height may change during window scrolling. + let oldRect = new Rect(this.visibleRect.x, this.visibleRect.y, this.size.width, this.size.height); + let newRect = new Rect(opts.visibleRect.x, opts.visibleRect.y, opts.size.width, opts.size.height); + let shouldInvalidate = this.layout.shouldInvalidate(newRect, oldRect); if (shouldInvalidate) { offsetChanged = !opts.visibleRect.pointEquals(this.visibleRect); - sizeChanged = !opts.visibleRect.sizeEquals(this.visibleRect); + sizeChanged = !this.size.equals(opts.size); needsLayout = true; } else { needsUpdate = true; } mutableThis.visibleRect = opts.visibleRect; + mutableThis.size = opts.size; } if (opts.invalidationContext !== this._invalidationContext) { diff --git a/packages/react-stately/src/virtualizer/types.ts b/packages/react-stately/src/virtualizer/types.ts index 6615b582531..b7e0adc3baa 100644 --- a/packages/react-stately/src/virtualizer/types.ts +++ b/packages/react-stately/src/virtualizer/types.ts @@ -13,6 +13,7 @@ import {Collection, Key} from '@react-types/shared'; import {Layout} from './Layout'; import {Rect} from './Rect'; +import {Size} from './Size'; export interface InvalidationContext { contentChanged?: boolean, @@ -34,6 +35,7 @@ export interface VirtualizerRenderOptions { collection: Collection, persistedKeys?: Set | null, visibleRect: Rect, + size: Size, invalidationContext: InvalidationContext, isScrolling: boolean, layoutOptions?: O diff --git a/packages/react-stately/src/virtualizer/useVirtualizerState.ts b/packages/react-stately/src/virtualizer/useVirtualizerState.ts index dfa5181991e..2a65125339a 100644 --- a/packages/react-stately/src/virtualizer/useVirtualizerState.ts +++ b/packages/react-stately/src/virtualizer/useVirtualizerState.ts @@ -32,12 +32,15 @@ interface VirtualizerProps { collection: Collection, onVisibleRectChange(rect: Rect): void, persistedKeys?: Set | null, - layoutOptions?: O + layoutOptions?: O, + allowsWindowScrolling?: boolean } export interface VirtualizerState { visibleViews: ReusableView[], setVisibleRect: (rect: Rect) => void, + size: Size, + setSize: (size: Size) => void, contentSize: Size, virtualizer: Virtualizer, isScrolling: boolean, @@ -47,6 +50,7 @@ export interface VirtualizerState { export function useVirtualizerState(opts: VirtualizerProps): VirtualizerState { let [visibleRect, setVisibleRect] = useState(new Rect(0, 0, 0, 0)); + let [size, setSize] = useState(new Size()); let [isScrolling, setScrolling] = useState(false); let [invalidationContext, setInvalidationContext] = useState({}); let visibleRectChanged = useRef(false); @@ -85,6 +89,7 @@ export function useVirtualizerState(opts: Virtuali persistedKeys: opts.persistedKeys, layoutOptions: opts.layoutOptions, visibleRect, + size: opts.allowsWindowScrolling ? size : visibleRect, invalidationContext: mergedInvalidationContext, isScrolling }); @@ -102,6 +107,8 @@ export function useVirtualizerState(opts: Virtuali virtualizer, visibleViews, setVisibleRect, + size, + setSize, contentSize, isScrolling, startScrolling, @@ -110,6 +117,8 @@ export function useVirtualizerState(opts: Virtuali virtualizer, visibleViews, setVisibleRect, + size, + setSize, contentSize, isScrolling, startScrolling, diff --git a/yarn.lock b/yarn.lock index fd94d936c89..42216ae15c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7171,6 +7171,8 @@ __metadata: json5: "npm:^2.2.3" lz-string: "npm:^1.5.0" markdown-to-jsx: "npm:^6.11.0" + mdast-util-mdx: "npm:^1.0.0" + mdast-util-to-markdown: "npm:^1.0.0" playwright: "npm:^1.57.0" react: "npm:^19.2.0" react-aria: "npm:^3.40.0"