From 7bc7e9295ec73a25318b45122418f2e8649684ba Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 20 Aug 2026 22:07:26 +0000 Subject: [PATCH 1/4] chore: fix missing translations script (#10488) * chore: fix missing translations script * fix script so it actually looks at react-stately/aria --- scripts/missingTranslations.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/missingTranslations.js b/scripts/missingTranslations.js index 96e226841e5..b0a5dd75379 100644 --- a/scripts/missingTranslations.js +++ b/scripts/missingTranslations.js @@ -1,21 +1,33 @@ -const glob = require('glob'); +const {globSync} = require('glob'); const fs = require('fs'); -for (let dir of glob.sync('packages/**/intl')) { - let en = JSON.parse(fs.readFileSync(`${dir}/en-US.json`, 'utf8')); +for (let enPath of globSync('packages/**/intl/**/en-US.json')) { + let dir = enPath.replace('/en-US.json', ''); + let en = JSON.parse(fs.readFileSync(enPath, 'utf8')); - for (let file of glob.sync('*.json', {cwd: dir})) { + for (let file of globSync('*.json', {cwd: dir})) { + if (file === 'en-US.json') { + continue; + } let lang = JSON.parse(fs.readFileSync(`${dir}/${file}`, 'utf8')); + let missing = []; let modified = false; for (let key in en) { if (!lang[key]) { + missing.push(key); lang[key] = en[key]; modified = true; } } if (modified) { + console.log(`\n${dir}/${file} — ${missing.length} missing key(s):`); + for (let key of missing) { + console.log(` - ${key}`); + } fs.writeFileSync(`${dir}/${file}`, JSON.stringify(lang, false, 2) + '\n'); } } } + +console.log('\nDone.'); From ecbb635569ff4d2cfeaa8e5047c8311d18a9daaa Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 20 Aug 2026 22:14:48 +0000 Subject: [PATCH 2/4] docs: fix agent skill links and update discovery index to RFC v0.2.0 (#10486) * fix: broken cross-file links in agent skills * updated the agent skills generation to match .well-known RFC v0.2.0 * fix lint * fix: use relative artifact URLs in agent skills discovery index Path-absolute URLs (`/.well-known/agent-skills/name.tar.gz`) resolve against the index's origin per the RFC's URL resolution rules, which drops any path prefix the index itself was served under. This broke installs from scoped preview URLs like PR builds (`https://host/pr//.well-known/agent-skills/index.json`), since the resolved artifact URL pointed at the production root instead of the PR path. Use same-directory relative URLs instead (`name.tar.gz`, `name/SKILL.md`), which resolve against the index's own directory and work under any deployment prefix. * fix: createSkillArchive now passes explicit top-level entry names to tar (via fs.readdirSync) instead of ., so no path gets a ./ prefix. --- .circleci/build-skills.sh | 4 +- .circleci/skills-diff.js | 4 +- .../s2-docs/scripts/generateAgentSkills.mjs | 232 ++++++++++++++++-- .../s2-docs/scripts/generateMarkdownDocs.mjs | 33 ++- 4 files changed, 230 insertions(+), 43 deletions(-) diff --git a/.circleci/build-skills.sh b/.circleci/build-skills.sh index 2c5b808216b..794ae07b834 100755 --- a/.circleci/build-skills.sh +++ b/.circleci/build-skills.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Build agent skills for the current working tree and copy the resulting -# .well-known/skills directories into $1 for later diffing. +# .well-known/agent-skills directories into $1 for later diffing. # # Runs the two node scripts directly (rather than via yarn) so the command # works from a `git worktree` / `git archive` checkout that doesn't have @@ -23,7 +23,7 @@ node packages/dev/s2-docs/scripts/generateAgentSkills.mjs rm -rf "$DEST" mkdir -p "$DEST" for lib in s2 react-aria; do - src="packages/dev/s2-docs/dist/$lib/.well-known/skills" + src="packages/dev/s2-docs/dist/$lib/.well-known/agent-skills" if [ -d "$src" ]; then mkdir -p "$DEST/$lib" cp -R "$src" "$DEST/$lib/" diff --git a/.circleci/skills-diff.js b/.circleci/skills-diff.js index 3f9f781c598..6171e7cbc03 100644 --- a/.circleci/skills-diff.js +++ b/.circleci/skills-diff.js @@ -152,7 +152,7 @@ function colorCounts(counts) { return parts.join(' '); } -// Map "s2/skills/" / "react-aria/skills/" (the layout produced +// Map "s2/agent-skills/" / "react-aria/agent-skills/" (the layout produced // by build-skills.sh) to a cloudfront URL on the branch build. function fileUrl(relPath, sha) { if (!sha) { @@ -161,7 +161,7 @@ function fileUrl(relPath, sha) { const parts = relPath.split(path.sep); const lib = parts[0]; const rest = parts.slice(1).join('/'); - // `rest` starts with "skills/...", the deploy lands it under /.well-known/ + // `rest` starts with "agent-skills/...", the deploy lands it under /.well-known/ let base; if (lib === 's2') { base = S2_BASE; diff --git a/packages/dev/s2-docs/scripts/generateAgentSkills.mjs b/packages/dev/s2-docs/scripts/generateAgentSkills.mjs index ca793ea3b5f..c8d544a9dd1 100644 --- a/packages/dev/s2-docs/scripts/generateAgentSkills.mjs +++ b/packages/dev/s2-docs/scripts/generateAgentSkills.mjs @@ -4,18 +4,23 @@ * Generates Agent Skills for React Spectrum (S2), migration, and React Aria. * * This script creates skills in the Agent Skills format (https://agentskills.io/specification) + * and publishes a discovery index per the Agent Skills Discovery via Well-Known URIs RFC + * (https://github.com/cloudflare/agent-skills-discovery-rfc), currently at v0.2.0. * * 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 + * 2. Create .well-known/agent-skills directories inside the docs dist output * 3. Copy relevant documentation to references/ subdirectories - * 4. Generate .well-known/skills/index.json for discovery. + * 4. Package each skill with supporting files as a `.tar.gz` archive + * 5. Generate .well-known/agent-skills/index.json for discovery, with a `type`, `url`, and + * SHA-256 `digest` per skill. */ -import {execSync} from 'child_process'; +import crypto from 'crypto'; +import {execFileSync, execSync} from 'child_process'; import {fileURLToPath} from 'url'; import fs from 'fs'; import path from 'path'; @@ -30,7 +35,8 @@ const MARKDOWN_DOCS_SCRIPT = path.join(__dirname, 'generateMarkdownDocs.mjs'); const MIGRATION_REFS_DIR = path.join(REPO_ROOT, 'packages/dev/s2-docs/migration-references'); const AUDIT_SKILL_SOURCE_DIR = path.join(REPO_ROOT, 'packages/dev/s2-docs/skills/spectrum-audit'); const WELL_KNOWN_DIR = '.well-known'; -const WELL_KNOWN_SKILLS_DIR = 'skills'; +const WELL_KNOWN_SKILLS_DIR = 'agent-skills'; +const DISCOVERY_SCHEMA = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json'; // Skill definitions const SKILLS = { @@ -738,6 +744,82 @@ This skill does not edit code. Recommend: ); } +/** + * Build a map from a doc's flat source-relative path (e.g. "forms.md", + * "internationalized/date/index.md") to the relative path it will be copied to + * inside a skill's `references/` directory (e.g. "guides/forms.md", + * "internationalized/date/index.md"). Used to rewrite cross-file links that were + * written for the docs site's flat URL structure so they resolve in the + * categorized `references/` layout the skill ships. + */ +function buildDestinationMap(categories, customGuideEntries) { + const destinationMap = new Map(); + + const addEntries = (entries, targetSubdir, stripPrefix = null) => { + for (const entry of entries) { + let targetRelPath = entry.path; + if (stripPrefix && targetRelPath.startsWith(stripPrefix)) { + targetRelPath = targetRelPath.slice(stripPrefix.length); + } + destinationMap.set(entry.path, path.posix.join(targetSubdir, targetRelPath)); + } + }; + + addEntries(customGuideEntries, 'guides'); + addEntries(categories.guides, 'guides'); + addEntries(categories.components, 'components'); + addEntries(categories.interactions, 'interactions'); + addEntries(categories.utilities, 'utilities'); + addEntries(categories.testing, 'testing'); + addEntries(categories.internationalized, 'internationalized', 'internationalized/'); + destinationMap.set('llms.txt', 'llms.txt'); + + return destinationMap; +} + +const LINK_PATTERN = /(\]\()([^)\s]+)(\))/g; +// Any URI with a scheme (http:, mailto:, cursor:, vscode:, s2:, etc.) is treated as opaque — +// only extension-less/bare relative paths refer to files shipped inside the skill. +const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; + +/** + * Rewrite relative Markdown links in `content` (a file whose flat source-relative + * path is `sourceRelPath`, being copied to `destRelPath` within `references/`) so + * that links pointing at other shipped docs resolve from the new, categorized + * location instead of the flat location they were authored for. + */ +function rewriteRelativeLinks(content, sourceRelPath, destRelPath, destinationMap) { + return content.replace(LINK_PATTERN, (match, prefix, href, suffix) => { + if (!href || href.startsWith('#') || URI_SCHEME_PATTERN.test(href)) { + return match; + } + + const urlMatch = href.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/); + if (!urlMatch) { + return match; + } + const [, pathPart, queryPart = '', hashPart = ''] = urlMatch; + if (!pathPart) { + return match; + } + + const resolvedSourcePath = path.posix.normalize( + path.posix.join(path.posix.dirname(sourceRelPath), pathPart) + ); + const target = destinationMap.get(resolvedSourcePath); + if (!target) { + return match; + } + + let newHref = path.posix.relative(path.posix.dirname(destRelPath), target); + if (!newHref.startsWith('.')) { + newHref = `./${newHref}`; + } + + return `${prefix}${newHref}${queryPart}${hashPart}${suffix}`; + }); +} + /** * Copy documentation files to the skill's references directory. */ @@ -745,6 +827,7 @@ function copyDocsDocumentation(skillConfig, categories, skillDir, options = {}) const refsDir = path.join(skillDir, 'references', options.referenceSubdir ?? ''); const sourceDir = path.join(MARKDOWN_DOCS_DIST, skillConfig.sourceDir); const customGuideEntries = getCustomGuideEntries(skillConfig.name); + const destinationMap = buildDestinationMap(categories, customGuideEntries); // Create subdirectories only if they have content const subdirs = [ @@ -776,7 +859,11 @@ function copyDocsDocumentation(skillConfig, categories, skillDir, options = {}) const targetPath = path.join(refsDir, targetSubdir, targetRelPath); fs.mkdirSync(path.dirname(targetPath), {recursive: true}); - fs.copyFileSync(sourcePath, targetPath); + + const destRelPath = path.posix.join(targetSubdir, targetRelPath); + const content = fs.readFileSync(sourcePath, 'utf8'); + const rewritten = rewriteRelativeLinks(content, entry.path, destRelPath, destinationMap); + fs.writeFileSync(targetPath, rewritten); }; // Copy guides @@ -857,6 +944,8 @@ function copyAdditionalReferenceLibraries(skillConfig, skillDir) { } function copyFocusedDocs(sourceDir, skillDir, docs) { + const destinationMap = new Map(docs); + for (const [sourceName, outputName] of docs) { const sourcePath = path.join(MARKDOWN_DOCS_DIST, sourceDir, sourceName); if (!fs.existsSync(sourcePath)) { @@ -866,7 +955,10 @@ function copyFocusedDocs(sourceDir, skillDir, docs) { const outputPath = path.join(skillDir, 'references', outputName); fs.mkdirSync(path.dirname(outputPath), {recursive: true}); - fs.copyFileSync(sourcePath, outputPath); + + const content = fs.readFileSync(sourcePath, 'utf8'); + const rewritten = rewriteRelativeLinks(content, sourceName, outputName, destinationMap); + fs.writeFileSync(outputPath, rewritten); } } @@ -952,43 +1044,129 @@ function collectSkillFiles(skillDir) { } /** - * Validate that all references/ links in SKILL.md resolve to actual files. - * Throws if any broken links are found. + * Validate that all relative links in every Markdown file that ships with the skill + * (SKILL.md and everything under references/) resolve to actual files, relative to + * the file that contains the link. Throws if any broken links are found. */ function validateSkillLinks(skillDir) { + const markdownFiles = []; + const walk = currentDir => { + for (const dirent of fs.readdirSync(currentDir, {withFileTypes: true})) { + const entryPath = path.join(currentDir, dirent.name); + if (dirent.isDirectory()) { + walk(entryPath); + } else if (dirent.isFile() && dirent.name.endsWith('.md')) { + markdownFiles.push(entryPath); + } + } + }; + const skillMdPath = path.join(skillDir, 'SKILL.md'); - if (!fs.existsSync(skillMdPath)) { - return; + if (fs.existsSync(skillMdPath)) { + markdownFiles.push(skillMdPath); + } + const referencesDir = path.join(skillDir, 'references'); + if (fs.existsSync(referencesDir)) { + walk(referencesDir); } - const content = fs.readFileSync(skillMdPath, 'utf8'); - const linkPattern = /\[([^\]]*)\]\((references\/[^)]+)\)/g; + const linkPattern = /\[([^\]]*)\]\(([^)\s]+)\)/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}`); + for (const filePath of markdownFiles) { + const content = fs.readFileSync(filePath, 'utf8'); + + let match; + while ((match = linkPattern.exec(content)) !== null) { + const linkText = match[1]; + let linkPath = match[2]; + + if (!linkPath || linkPath.startsWith('#') || URI_SCHEME_PATTERN.test(linkPath)) { + continue; + } + + // Strip query params and hash fragments before resolving the file on disk + linkPath = linkPath.replace(/[?#].*$/, ''); + if (!linkPath) { + continue; + } + + // The legacy v3 docs site isn't part of this build, so its links can't be verified here. + if (/(^|\/)v3\//.test(linkPath)) { + continue; + } + + const resolvedPath = path.resolve(path.dirname(filePath), linkPath); + if (fs.existsSync(resolvedPath)) { + continue; + } + + // Some skills only bundle a curated subset of the full docs (e.g. blog posts, the site's + // own landing page, or a "focused" reference set). A link to a page that exists somewhere + // in the full generated docs corpus, just not in this skill's bundle, is a known, accepted + // gap rather than a broken/typo'd link. + const bareLinkPath = linkPath.replace(/^(\.\.\/)+/, '').replace(/^\.\//, ''); + const existsInFullCorpus = ['s2', 'react-aria'].some(lib => + fs.existsSync(path.join(MARKDOWN_DOCS_DIST, lib, bareLinkPath)) + ); + if (!existsInFullCorpus) { + broken.push(`${path.relative(REPO_ROOT, filePath)}: "${linkText}" -> ${match[2]}`); + } } } if (broken.length > 0) { - throw new Error( - `Broken references in ${path.relative(REPO_ROOT, skillMdPath)}:\n ${broken.join('\n ')}` - ); + throw new Error(`Broken links found:\n ${broken.join('\n ')}`); } } function writeIndexJson(wellKnownRoot, skills) { const indexPath = path.join(wellKnownRoot, 'index.json'); - const payload = {skills}; + const payload = {$schema: DISCOVERY_SCHEMA, skills}; fs.writeFileSync(indexPath, JSON.stringify(payload, null, 2) + '\n'); console.log(`Generated ${path.relative(REPO_ROOT, indexPath)}`); } +function sha256Digest(filePath) { + const hash = crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); + return `sha256:${hash}`; +} + +/** + * Package a skill directory as a `.tar.gz` archive at the well-known root, with `SKILL.md` + * and any supporting files (references/, etc.) at the archive root — per the Archive + * Distribution section of the Agent Skills Discovery RFC. + */ +function createSkillArchive(skillDir, skillName, wellKnownRoot) { + const archivePath = path.join(wellKnownRoot, `${skillName}.tar.gz`); + const entries = fs.readdirSync(skillDir).filter(name => name !== '.DS_Store'); + execFileSync('tar', ['--exclude=.DS_Store', '-czf', archivePath, '-C', skillDir, ...entries]); + return archivePath; +} + +/** + * Build the discovery index entry's distribution fields (`type`, `url`, `digest`) for a + * generated skill. Skills consisting only of `SKILL.md` are published as `type: "skill-md"`; + * skills with supporting files (references/, etc.) are packaged as a `type: "archive"` tarball. + */ +function buildSkillArtifact(skillConfig, skillDir, wellKnownRoot, files) { + if (files.length === 1 && files[0] === 'SKILL.md') { + return { + type: 'skill-md', + url: `${skillConfig.name}/SKILL.md`, + digest: sha256Digest(path.join(skillDir, 'SKILL.md')) + }; + } + + const archivePath = createSkillArchive(skillDir, skillConfig.name, wellKnownRoot); + console.log(`Generated ${path.relative(REPO_ROOT, archivePath)}`); + return { + type: 'archive', + url: `${skillConfig.name}.tar.gz`, + digest: sha256Digest(archivePath) + }; +} + /** * Generate a single skill. */ @@ -1077,14 +1255,14 @@ function main() { const skillDir = generateSkill(config, wellKnownRoot); validateSkillLinks(skillDir); const files = collectSkillFiles(skillDir); + const artifact = buildSkillArtifact(config, skillDir, wellKnownRoot, files); const entry = { name: config.name, + type: artifact.type, description: config.description, - files + url: artifact.url, + digest: artifact.digest }; - if (config.kind) { - entry.kind = config.kind; - } indexEntries.push(entry); } diff --git a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs index b1c8febc21f..b4e27e11ab2 100644 --- a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs +++ b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs @@ -991,18 +991,27 @@ function getTypeText(decl, fallbackContext) { return 'unknown'; } +/** + * Resolve the `s2:` and `react-aria:` cross-library URL schemes (used to link from one + * doc site to another, e.g. from an S2 component page to its React Aria counterpart) + * into an absolute URL on the target site. Other URLs are returned unchanged. + */ +function resolveSchemeUrl(href) { + if (href && (href.startsWith('s2:') || href.startsWith('react-aria:'))) { + const url = new URL(href); + return getBaseUrl(url.protocol.slice(0, -1)) + '/' + url.pathname; + } + return href; +} + +const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; + /** * Transform relative URLs to use .md extension instead of .html or no extension. * Preserves query params and hash fragments. */ function transformRelativeUrl(href) { - if ( - !href || - href.startsWith('http://') || - href.startsWith('https://') || - href.startsWith('mailto:') || - href.startsWith('#') - ) { + if (!href || href.startsWith('#') || URI_SCHEME_PATTERN.test(href)) { return href; } @@ -1017,6 +1026,9 @@ function transformRelativeUrl(href) { if (pathPart.endsWith('.html')) { // Replace .html with .md pathPart = pathPart.slice(0, -5) + '.md'; + } else if (pathPart.endsWith('/')) { + // A trailing slash refers to that directory's index page + pathPart = pathPart + 'index.md'; } else if (pathPart && !pathPart.match(/\.[a-zA-Z0-9]+$/)) { // Add .md to paths without an extension pathPart = pathPart + '.md'; @@ -2695,10 +2707,7 @@ function remarkDocsComponentsToMarkdown() { } } - if (href && (href.startsWith('s2:') || href.startsWith('react-aria:'))) { - let url = new URL(href); - href = getBaseUrl(url.protocol.slice(0, -1)) + '/' + url.pathname; - } + href = resolveSchemeUrl(href); // Convert .html links to .md for relative links if (href && !href.startsWith('http') && !href.startsWith('//') && href.endsWith('.html')) { @@ -3283,7 +3292,7 @@ function remarkDocsComponentsToMarkdown() { // Transform relative links to use .md extension. visit(tree, 'link', node => { - node.url = transformRelativeUrl(node.url); + node.url = transformRelativeUrl(resolveSchemeUrl(node.url)); }); // Append "Related Types" section if we collected any. From ea4dbc09b439f84105f8ce08a6ee7350f87280ee Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 20 Aug 2026 22:34:34 +0000 Subject: [PATCH 3/4] fix: make sure avatars appear if Combobox/Picker is in Dialog (#10482) * fix: make sure avatars appear if Combobox/Picker is in Dialog * make avatar clear the context --- .../s2/chromatic/Dialog.stories.tsx | 92 ++++++++++++++++++- packages/@react-spectrum/s2/src/Avatar.tsx | 32 ++++--- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/Dialog.stories.tsx b/packages/@react-spectrum/s2/chromatic/Dialog.stories.tsx index 2fa35b0357b..0cd02c9c4fc 100644 --- a/packages/@react-spectrum/s2/chromatic/Dialog.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Dialog.stories.tsx @@ -10,6 +10,11 @@ * governing permissions and limitations under the License. */ +import {Avatar} from '../src/Avatar'; +import {Button} from '../src/Button'; +import {ButtonGroup} from '../src/ButtonGroup'; +import {ComboBox, ComboBoxItem} from '../src/ComboBox'; +import {Content, Heading, Text} from '../src/Content'; import {Dialog} from '../src/Dialog'; import { DialogContainerExample, @@ -17,8 +22,11 @@ import { Example, ExampleStoryType } from '../stories/Dialog.stories'; +import {DialogTrigger} from '../src/DialogTrigger'; +import {expect} from '@storybook/jest'; import type {Meta, StoryObj} from '@storybook/react'; -import {userEvent, within} from 'storybook/test'; +import {Picker, PickerItem} from '../src/Picker'; +import {userEvent, waitFor, within} from 'storybook/test'; const meta: Meta = { component: Dialog, @@ -57,3 +65,85 @@ export const DialogContainer: Story = { ...DialogContainerExample, play: async context => await Default.play!(context) }; + +export const ComboBoxAvatarInDialog: Story = { + name: 'Combobox avatar in Dialog', + render: () => ( + + + + Share with people + + + + + User One + user.one@example.com + + + + + + + + + + ), + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{Enter}'); + let body = canvasElement.ownerDocument.body; + await within(body).findByRole('dialog'); + await new Promise(resolve => setTimeout(resolve, 1000)); + let combobox = within(body).getByRole('combobox'); + await userEvent.click(combobox); + await userEvent.keyboard('{ArrowDown}'); + let listbox = await within(body).findByRole('listbox'); + await waitFor( + () => { + expect(within(listbox).getByText('User One', {exact: false})).toBeInTheDocument(); + }, + {timeout: 5000} + ); + } +}; + +export const PickerAvatarInDialog: Story = { + name: 'Picker avatar in Dialog', + render: () => ( + + + + Share with people + + + + + User One + + + + + + + + + + ), + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{Enter}'); + let body = canvasElement.ownerDocument.body; + await within(body).findByRole('dialog'); + await new Promise(resolve => setTimeout(resolve, 1000)); + let picker = within(body).getByRole('button', {name: /Owner/i}); + await userEvent.click(picker); + let listbox = await within(body).findByRole('listbox'); + await waitFor( + () => { + expect(within(listbox).getByText('User One', {exact: false})).toBeInTheDocument(); + }, + {timeout: 5000} + ); + } +}; diff --git a/packages/@react-spectrum/s2/src/Avatar.tsx b/packages/@react-spectrum/s2/src/Avatar.tsx index 20c8347a5ba..2aa6396b268 100644 --- a/packages/@react-spectrum/s2/src/Avatar.tsx +++ b/packages/@react-spectrum/s2/src/Avatar.tsx @@ -20,7 +20,7 @@ import { StylesPropWithoutWidth, UnsafeStyles } from './style-utils' with {type: 'macro'}; -import {Image} from './Image'; +import {Image, ImageContext} from './Image'; import {isDocsEnv} from './macros' with {type: 'macro'}; import {style} from '../style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; @@ -93,19 +93,21 @@ export const Avatar = forwardRef(function Avatar( let remSize = isDocsEnv() ? `calc(${size / 16} * var(--rem, 1rem))` : `${size / 16}rem`; let isLarge = size >= 64; return ( - {alt} + + {alt} + ); }); From 5d191ab94472daa8fa53d02e3c425639c2f381a7 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 20 Aug 2026 23:08:14 +0000 Subject: [PATCH 4/4] feat: RAC NavigationTree (#10404) * feat: RAC SideNav * fix ts * fix again * Convert docs to use S2 SideNav and move scrolling logic inside the component * fix tests * remove api not supported * remove disabled docs section * Rename RAC SideNav to NavigationTree * make rac style examples match s2 more * update docs from review * Always default to having Components open * add anatomy diagram and fix fonts in parcel 3 svgs --- packages/@react-spectrum/s2/src/SideNav.tsx | 355 ++--------- .../pages/react-aria/NavigationTree.mdx | 192 ++++++ .../react-aria/NavigationTreeAnatomy.svg | 72 +++ .../pages/react-aria/RoutedNavigationTree.tsx | 17 + .../dev/s2-docs/pages/react-aria/router.tsx | 37 ++ packages/dev/s2-docs/src/Nav.tsx | 253 ++++---- packages/dev/s2-docs/src/anatomy.css | 4 + packages/dev/s2-docs/src/client.tsx | 1 - .../exports/NavigationTree.ts | 41 ++ .../react-aria-components/exports/index.ts | 18 + .../src/NavigationTree.tsx | 480 +++++++++++++++ .../stories/NavigationTree.stories.tsx | 115 ++++ .../test/NavigationTree.test.tsx | 552 ++++++++++++++++++ starters/docs/src/NavigationTree.css | 158 +++++ starters/docs/src/NavigationTree.tsx | 70 +++ starters/tailwind/src/NavigationTree.tsx | 139 +++++ 16 files changed, 2069 insertions(+), 435 deletions(-) create mode 100644 packages/dev/s2-docs/pages/react-aria/NavigationTree.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/NavigationTreeAnatomy.svg create mode 100644 packages/dev/s2-docs/pages/react-aria/RoutedNavigationTree.tsx create mode 100644 packages/dev/s2-docs/pages/react-aria/router.tsx create mode 100644 packages/react-aria-components/exports/NavigationTree.ts create mode 100644 packages/react-aria-components/src/NavigationTree.tsx create mode 100644 packages/react-aria-components/stories/NavigationTree.stories.tsx create mode 100644 packages/react-aria-components/test/NavigationTree.test.tsx create mode 100644 starters/docs/src/NavigationTree.css create mode 100644 starters/docs/src/NavigationTree.tsx create mode 100644 starters/tailwind/src/NavigationTree.tsx diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 3c489729283..f1388f02f38 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -22,83 +22,39 @@ import { UnsafeStyles } from './style-utils' with {type: 'macro'}; import Chevron from '../ui-icons/Chevron'; -import { - Collection, - DOMRef, - forwardRefType, - GlobalDOMAttributes, - Key, - Node, - RouterOptions -} from '@react-types/shared'; -import { - createContext, - forwardRef, - ReactNode, - RefObject, - useContext, - useEffect, - useRef, - useState -} from 'react'; +import {createContext, forwardRef, ReactNode, useContext, useRef, useState} from 'react'; +import {DOMRef, forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; import {IconContext} from './Icon'; import {Link} from 'react-aria-components/Link'; +import { + NavigationTree, + NavigationTreeHeader, + NavigationTreeHeaderProps, + NavigationTreeItem, + NavigationTreeItemContent, + NavigationTreeItemContentRenderProps, + NavigationTreeItemProps, + NavigationTreeProps, + NavigationTreeSection, + NavigationTreeSectionProps +} from 'react-aria-components/NavigationTree'; import {pressScale} from './pressScale'; import {Provider, useContextProps} from 'react-aria-components/slots'; -import { - TreeItemProps as RACTreeItemProps, - TreeProps as RACTreeProps, - Tree, - TreeHeader, - TreeHeaderProps, - TreeItem, - TreeItemContent, - TreeItemContentProps, - TreeItemRenderProps, - TreeRenderProps, - TreeSection, - TreeSectionProps -} from 'react-aria-components/Tree'; import {Text, TextContext} from './Content'; -import {TreeState} from 'react-stately/useTreeState'; import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useScale} from './utils'; export interface SideNavProps extends - Omit< - RACTreeProps, - | 'style' - | 'className' - | 'render' - | 'onAction' - | 'onRowAction' - | 'selectionBehavior' - | 'onScroll' - | 'onCellAction' - | 'onSelectionChange' - | 'selectedKeys' - | 'defaultSelectedKeys' - | 'disabledBehavior' - | 'selectionMode' - | 'escapeKeyBehavior' - | 'shouldSelectOnPressUp' - | 'disallowEmptySelection' - | 'renderEmptyState' - | 'keyboardNavigationBehavior' - | 'dragAndDropHooks' // To be implemented - | keyof GlobalDOMAttributes - >, + Omit, 'style' | 'className' | 'render' | keyof GlobalDOMAttributes>, UnsafeStyles { - /** The route that is currently selected. */ - selectedRoute?: string | null; /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight; } export interface SideNavItemProps extends Omit< - RACTreeItemProps, + NavigationTreeItemProps, | 'className' | 'style' | 'render' @@ -132,7 +88,7 @@ const sideNavWrapper = style( // TODO: the below is needed so the borders of the top and bottom row isn't cut off if the TreeView is wrapped within a container by always reserving the 2px needed for the // keyboard focus ring. Perhaps find a different way of rendering the outlines since the top of the item doesn't // scroll into view due to how the ring is offset. Alternatively, have the tree render the top/bottom outline like it does in Listview -const tree = style({ +const tree = style({ ...focusRing(), outlineOffset: -2, // make certain we are visible inside overflow hidden containers userSelect: 'none', @@ -151,14 +107,6 @@ const tree = style({ } }); -interface InternalSideNavContextValue { - /** The route that is currently selected. */ - selectedRoute?: string | null; - /** The last route the focused key was synced to; dedupes the focus sync across items. */ - syncedRouteRef?: RefObject; -} -let InternalSideNavContext = createContext({}); - /** * A SideNav provides users with a way to navigate nested hierarchical set of links. */ @@ -170,29 +118,22 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid let domRef = useDOMRef(ref); - // Tracks the last route we moved the focused key to, so the focus sync (driven from - // RouteFocusSync, which has the built collection) only runs when the route actually changes - let syncedRouteRef = useRef(undefined); - return (
- - tree(renderProps)} - selectionMode="none" - keyboardNavigationBehavior="tab"> - {children} - - + tree(renderProps)}> + {children} +
); }); -const treeRow = style({ +const treeRow = style({ outlineStyle: 'none', position: 'relative', display: 'flex', @@ -308,18 +249,6 @@ const treeActionMenu = style({ const SideNavItemLinkContext = createContext<{ isDisabled?: boolean; - href?: string; - hrefLang?: string; - target?: string; - rel?: string; - download?: string | boolean; - ping?: string; - referrerPolicy?: ReferrerPolicy; - routerOptions?: RouterOptions; - // Lets the row track whether the link (as opposed to another focusable child like an ActionMenu - // trigger) is the focused element, so the row focus ring can follow the link specifically. - onFocusChange?: (isFocused: boolean) => void; - // So we can scale the row when the link is pressed. onPressChange?: (isPressed: boolean) => void; }>({}); @@ -328,42 +257,24 @@ const SideNavInternalItemContext = createContext<{setLinkPressed?: (isPressed: b ); export const SideNavItem = (props: SideNavItemProps): ReactNode => { - let {href, hrefLang, target, rel, download, ping, referrerPolicy, routerOptions, ...rest} = props; - - let hasLink = href != null && href.length > 0; let [isLinkPressed, setLinkPressed] = useState(false); let rowRef = useRef(null); // oxlint-disable-next-line react-compiler let scaling = pressScale(rowRef); return ( - - - scaling({isPressed: isLinkPressed || isPressed})} - href={href} - focusMode={hasLink ? 'child' : undefined} - allowsArrowNavigation - className={renderProps => treeRow(renderProps)} - /> - - + + scaling({isPressed: isLinkPressed || isPressed})} + className={renderProps => treeRow(renderProps)} + /> + ); }; -export interface SideNavItemContentProps extends Omit { +export interface SideNavItemContentProps { /** Rendered contents of the side nav item or child items. */ children: ReactNode; } @@ -399,74 +310,18 @@ const indicator = style<{isDisabled: boolean; isSelected: boolean; isHovered: bo borderRadius: 'full' }); -// Moves the tree's focused key to the item matching selectedRoute. Lives in items -// (rather than up in SideNav) because it needs the built collection off `state`, which only exists -// after the tree has rendered. Runs when the route or the collection changes; the shared -// syncedRouteRef dedupes across items so it fires once per route change. -// If the item is inside a collapsed parent, the focused key is moved to the closest -// visible ancestor instead of the hidden descendant. -function useRouteFocusSync({state}: {state: TreeState}): void { - let {selectedRoute, syncedRouteRef} = useContext(InternalSideNavContext); - let {collection, selectionManager, expandedKeys} = state; - useEffect(() => { - if ( - selectedRoute == null || - syncedRouteRef == null || - syncedRouteRef.current === selectedRoute - ) { - return; - } - let key = findKeyForRoute(collection, selectedRoute); - if (key != null) { - key = closestVisibleKey(collection, expandedKeys, key); - syncedRouteRef.current = selectedRoute; - selectionManager.setFocusedKey(key); - } - }, [selectedRoute, collection, expandedKeys, syncedRouteRef, selectionManager]); -} - export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => { let {children} = props; let scale = useScale(); - let linkProps = useContext(SideNavItemLinkContext); let {setLinkPressed} = useContext(SideNavInternalItemContext); - let {selectedRoute} = useContext(InternalSideNavContext); - return ( - - {({ - isExpanded, - hasChildItems, - isDisabled, - isSelected, - id, - state, - isHovered, - isPressed, - isFocusVisible, - isFocusVisibleWithin - }) => { - return ( - - {children} - - ); - }} - + + {(renderProps: NavigationTreeItemContentRenderProps) => ( + + {children} + + )} + ); }; @@ -475,48 +330,34 @@ const SideNavItemContentInner = props => { isExpanded, hasChildItems, isDisabled, - isSelected, - setLinkPressed, - linkProps, - scale, - id, - state, - selectedRoute, + isCurrent, + isCurrentAncestor, isHovered, isFocusVisible, - isFocusVisibleWithin, + scale, + setLinkPressed, children } = props; - useRouteFocusSync({state}); - - // Whether the link within this row is the focused element (any modality). Combined with the - // keyboard-only isFocusVisibleWithin below, this lets the row focus ring follow the link - // specifically and not other focusable children (e.g. an ActionMenu trigger). - let [isLinkFocused, setLinkFocused] = useState(false); - - let hasLink = linkProps.href != null && linkProps.href.length > 0; - return ( <>
{ extends Omit< - TreeSectionProps, + NavigationTreeSectionProps, 'value' | 'render' | 'style' | 'className' > {} export function SideNavSection(props: SideNavSectionProps) { return ( - + {props.children} - + ); } export interface SideNavHeaderProps extends Omit< - TreeHeaderProps, + NavigationTreeHeaderProps, 'value' | 'render' | 'style' | 'className' > {} export const SideNavHeader = (props: SideNavHeaderProps): ReactNode => { return ( - { height: 16 })}> {props.children} - + ); }; @@ -681,15 +518,10 @@ export interface SideNavItemLinkProps { export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { let {children} = props; - let {selectedRoute} = useContext(InternalSideNavContext); - let linkProps = useContext(SideNavItemLinkContext); + let linkFocus = useContext(SideNavItemLinkContext); return ( - + { ); }; - -// The collection key of the item whose href matches `route`, or null. getKeys() covers collapsed -// items too, and the href is stored as a data attribute so it doesn't trigger Tree's link handling. -function findKeyForRoute(collection: Collection>, route: string): Key | null { - for (let key of collection.getKeys()) { - if (collection.getItem(key)?.props?.href === route) { - return key; - } - } - return null; -} - -// Walks up from `key` to the closest ancestor that is actually rendered (i.e. all of its ancestors -// are expanded). Returns `key` unchanged when it is already visible. A collapsed ancestor hides -// everything beneath it, so the highest collapsed ancestor is the closest visible row. -function closestVisibleKey( - collection: Collection>, - expandedKeys: Set, - key: Key -): Key { - let target = key; - let node = collection.getItem(key); - while (node?.parentKey != null) { - let parent = collection.getItem(node.parentKey); - if (parent?.type === 'item' && !expandedKeys.has(node.parentKey)) { - target = node.parentKey; - } - node = parent; - } - return target; -} - -// Cache so each row doesn't have to walk up the tree every time -let selectedAncestorsCache = new WeakMap< - Collection>, - {selection: unknown; ancestors: Set} ->(); - -// The set of collection keys that are ancestors of the item matching `selectedRoute`. -function getSelectedAncestors(state: TreeState, selectedRoute: string): Set { - let {collection} = state; - let cached = selectedAncestorsCache.get(collection); - if (cached && cached.selection === selectedRoute) { - return cached.ancestors; - } - - let matchKey = findKeyForRoute(collection, selectedRoute); - - let ancestors = new Set(); - let node = matchKey != null ? collection.getItem(matchKey) : null; - while (node?.parentKey != null && !ancestors.has(node.parentKey)) { - ancestors.add(node.parentKey); - node = collection.getItem(node.parentKey); - } - - selectedAncestorsCache.set(collection, {selection: selectedRoute, ancestors}); - return ancestors; -} - -// Whether the row `id` is an ancestor of the item matching `selectedRoute`, i.e. it has a -// selected descendant. Used to keep a collapsed parent styled when its selected child is hidden. -function hasSelectedDescendant( - id: Key | undefined, - state: TreeState, - selectedRoute: string | undefined -) { - if (id == null || selectedRoute == null || !state) { - return false; - } - return getSelectedAncestors(state, selectedRoute).has(id); -} diff --git a/packages/dev/s2-docs/pages/react-aria/NavigationTree.mdx b/packages/dev/s2-docs/pages/react-aria/NavigationTree.mdx new file mode 100644 index 00000000000..b2ccc1a31de --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/NavigationTree.mdx @@ -0,0 +1,192 @@ +import {Layout} from '../../src/Layout'; +export default Layout; + +import docs from 'docs:react-aria-components'; +import '../../tailwind/tailwind.css'; +import {InlineAlert, Heading, Content} from '@react-spectrum/s2'; +import Anatomy from './NavigationTreeAnatomy.svg'; + +export const tags = ['navigation', 'nav', 'sidebar']; +export const version = 'alpha'; +export const description = 'A navigation component that displays a nested, hierarchical set of links, with support for keyboard navigation and a current route indicator.'; + +# NavigationTree + +{docs.exports.NavigationTree.description} + + + ```tsx render docs={docs.exports.NavigationTree} links={docs.links} props={[]} type="vanilla" files={["starters/docs/src/NavigationTree.tsx", "starters/docs/src/NavigationTree.css", "packages/dev/s2-docs/pages/react-aria/RoutedNavigationTree.tsx"]} + "use client"; + import {NavigationTree, NavigationTreeItem} from 'vanilla-starter/NavigationTree'; + import {RoutedNavigationTree} from './RoutedNavigationTree'; + + + {({selectedRoute}) => ( + + + + + + + + + + + + )} + + ``` + + ```tsx render docs={docs.exports.NavigationTree} links={docs.links} props={[]} type="tailwind" files={["starters/tailwind/src/NavigationTree.tsx", "packages/dev/s2-docs/pages/react-aria/RoutedNavigationTree.tsx"]} + "use client"; + import {NavigationTree, NavigationTreeItem} from 'tailwind-starter/NavigationTree'; + import {RoutedNavigationTree} from './RoutedNavigationTree'; + + + {({selectedRoute}) => ( + + + + + + + + + + + + )} + + ``` + + + + + Accessibility + `NavigationTree` renders as a tree so keyboard users can navigate and expand the hierarchy. When it acts as the main navigation for a page, place it inside a [navigation landmark](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/navigation.html): wrap the `NavigationTree` in a `
+ )} + + {children} + + ); +} + +export const Example = (args: any) => ( + + {({selectedRoute}) => ( + + + + + + + + + + + + )} + +); diff --git a/packages/react-aria-components/test/NavigationTree.test.tsx b/packages/react-aria-components/test/NavigationTree.test.tsx new file mode 100644 index 00000000000..b8a02e71539 --- /dev/null +++ b/packages/react-aria-components/test/NavigationTree.test.tsx @@ -0,0 +1,552 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {Button} from '../src/Button'; +import {Link} from '../src/Link'; +import { + NavigationTree, + NavigationTreeHeader, + NavigationTreeItem, + NavigationTreeItemContent, + NavigationTreeProps, + NavigationTreeSection +} from '../src/NavigationTree'; +import React from 'react'; +import userEvent, {UserEvent} from '@testing-library/user-event'; + +// libraries > (projects-1, projects-2); files is a top-level leaf. +function NavigationTreeExample(props: Partial>) { + return ( + + + + Your files + + + + + Your libraries + + + + + Projects 1 + + + + + ); +} + +// files (leaf) then libraries > projects-1 > projects-1A +function ThreeLevelNavigationTreeExample(props: Partial>) { + return ( + + + + Your files + + + + + Your libraries + + + + + Projects 1 + + + + + Projects 1A + + + + + + ); +} + +// files (leaf) then a no-href "Section" row with a secondary-action button (not a chevron) and +// a linked child. Focus should land on the row itself, not jump into the secondary action. +function NoLinkActionMenuNavigationTreeExample(props: Partial>) { + return ( + + + + Your files + + + + + Section + + + + + + Section 2 + + + + + ); +} + +// Section 1 has an aria-label instead of a NavigationTreeHeader (as is allowed by the underlying +// TreeSection); Section 2 uses a NavigationTreeHeader so both default classes get covered. +function SectionNavigationTreeExample(props: Partial>) { + return ( + + + + + Your files + + + + + Section 2 + + + Your libraries + + + + + ); +} + +describe('NavigationTree', () => { + let user: UserEvent; + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + jest.useFakeTimers(); + }); + afterEach(() => { + act(() => jest.runAllTimers()); + }); + + it('renders a treegrid with default classes and nested items', () => { + let {getByRole, getAllByRole} = render(); + let NavigationTree = getByRole('treegrid'); + expect(NavigationTree).toHaveClass('react-aria-NavigationTree'); + expect(NavigationTree).toHaveAttribute('aria-label', 'Test NavigationTree'); + let rows = getAllByRole('row'); + expect(rows[0]).toHaveClass('react-aria-NavigationTreeItem'); + expect(getByRole('link', {name: 'Your files'})).toBeInTheDocument(); + }); + + it('expands and collapses a level with the chevron', async () => { + let {getByRole, queryByRole} = render(); + let librariesRow = getByRole('row', {name: 'Your libraries'}); + expect(librariesRow).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + + await user.click(within(librariesRow).getByRole('button')); + expect(librariesRow).toHaveAttribute('aria-expanded', 'true'); + expect(getByRole('link', {name: 'Projects 1'})).toBeInTheDocument(); + }); + + it('exposes row render props via NavigationTreeItem className function', () => { + let {getByRole} = render( + + `lvl-${level}`}> + + Your files + + + + ); + expect(getByRole('row', {name: 'Your files'})).toHaveClass('lvl-1'); + }); + + it('marks the link matching selectedRoute with aria-current and data-current on the row', () => { + let {getByRole, rerender} = render(); + expect(getByRole('link', {name: 'Your files'})).toHaveAttribute('aria-current', 'page'); + expect(getByRole('row', {name: 'Your files'})).toHaveAttribute('data-current', 'true'); + expect(getByRole('link', {name: 'Your libraries'})).not.toHaveAttribute('aria-current'); + expect(getByRole('row', {name: 'Your libraries'})).not.toHaveAttribute('data-current'); + + rerender(); + expect(getByRole('link', {name: 'Your files'})).not.toHaveAttribute('aria-current'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveAttribute('aria-current', 'page'); + }); + + it('exposes isCurrent through NavigationTreeItemContent render props', () => { + let {getByRole} = render( + + + + {({isCurrent}) => {isCurrent ? 'current' : 'not'}} + + + + ); + expect(getByRole('link')).toHaveTextContent('current'); + }); + + it('initial focus moves to the selected route', async () => { + let {getByRole} = render( + + ); + await user.tab(); + expect(getByRole('link', {name: 'Projects 1'})).toHaveFocus(); + }); + + it('falls back to the closest visible ancestor when selectedRoute is under a collapsed parent', async () => { + let {getByRole, queryByRole} = render( + + ); + expect(getByRole('row', {name: 'Your libraries'})).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + await user.tab(); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + }); + + it('skips an expanded-but-hidden (by something higher up) ancestor and lands on the closest rendered ancestor', async () => { + let {getByRole, queryByRole} = render( + + ); + expect(getByRole('row', {name: 'Your libraries'})).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + await user.tab(); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + }); + + it('exposes isCurrentAncestor (render prop + data-current-ancestor) for every ancestor of the current item, regardless of expanded state', async () => { + function Example(props: Partial>) { + return ( + + + + {({isCurrentAncestor}) => ( + <> + Your libraries + + {String(isCurrentAncestor)} + + )} + + + + Projects 1 + + + + + ); + } + // Collapsed parent whose descendant is current. + let {getByTestId, getByRole, unmount} = render(); + expect(getByTestId('anc')).toHaveTextContent('true'); + expect(getByRole('row', {name: 'Your libraries'})).toHaveAttribute( + 'data-current-ancestor', + 'true' + ); + unmount(); + + // Expanded parent whose descendant is current. + ({getByTestId, getByRole, unmount} = render( + + )); + expect(getByTestId('anc')).toHaveTextContent('true'); + expect(getByRole('row', {name: 'Your libraries'})).toHaveAttribute( + 'data-current-ancestor', + 'true' + ); + unmount(); + + // Parent itself current, not an ancestor of itself. + ({getByTestId, getByRole} = render()); + expect(getByTestId('anc')).toHaveTextContent('false'); + expect(getByRole('row', {name: 'Your libraries'})).not.toHaveAttribute('data-current-ancestor'); + }); + + it('expands/collapses with ArrowRight/ArrowLeft on the link', async () => { + let {getByRole, queryByRole} = render(); + let librariesRow = getByRole('row', {name: 'Your libraries'}); + await user.tab(); + await user.keyboard('{ArrowDown}'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(librariesRow).toHaveAttribute('aria-expanded', 'true'); + expect(getByRole('link', {name: 'Projects 1'})).toBeInTheDocument(); + await user.keyboard('{ArrowLeft}'); + expect(librariesRow).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + }); + + it('takes one tab to leave the NavigationTree from a link', async () => { + let {getByRole} = render( + <> + + + + ); + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + await user.tab(); + expect(getByRole('textbox')).toHaveFocus(); + }); + + it('takes one shift+tab to leave the NavigationTree from a link', async () => { + let {getByRole} = render( + <> + + + + ); + await user.tab(); + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + await user.tab({shift: true}); + expect(getByRole('textbox')).toHaveFocus(); + }); + + it('keeps focus on the row (not a secondary action) for an item with no href/link', async () => { + let {getByRole} = render(); + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + + // Focus stays on the row itself; it does not jump into the secondary-action button. + let sectionRow = getByRole('row', {name: 'Section'}); + expect(sectionRow).toHaveFocus(); + expect(within(sectionRow).getByRole('button', {name: 'More actions'})).not.toHaveFocus(); + }); + + it('arrow left from a deep leaf steps to parent, collapses it, then moves to the grandparent', async () => { + let {getByRole, queryByRole} = render( + + ); + await user.tab(); + expect(getByRole('link', {name: 'Projects 1A'})).toHaveFocus(); + + // 1st ArrowLeft: leaf has nothing to collapse, so focus moves up to its parent. + await user.keyboard('{ArrowLeft}'); + expect(getByRole('link', {name: 'Projects 1'})).toHaveFocus(); + expect(getByRole('row', {name: 'Projects 1'})).toHaveAttribute('aria-expanded', 'true'); + + // 2nd ArrowLeft: the parent is expanded, so it collapses; focus stays on it. + await user.keyboard('{ArrowLeft}'); + expect(getByRole('row', {name: 'Projects 1'})).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1A'})).toBeNull(); + expect(getByRole('link', {name: 'Projects 1'})).toHaveFocus(); + + // 3rd ArrowLeft: focus moves up to the grandparent. + await user.keyboard('{ArrowLeft}'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + }); + + it('should render a NavigationTree with default classes, including sections and headers', () => { + let {getByRole, getAllByRole} = render(); + let NavigationTree = getByRole('treegrid'); + expect(NavigationTree).toHaveClass('react-aria-NavigationTree'); + + let rows = getAllByRole('row'); + // The header is also exposed with role="row", but it gets 'react-aria-NavigationTreeHeader' + // instead of 'react-aria-NavigationTreeItem', so it is excluded from this loop. + let itemRows = rows.filter(row => !row.classList.contains('react-aria-NavigationTreeHeader')); + expect(itemRows).toHaveLength(2); + for (let row of itemRows) { + expect(row).toHaveClass('react-aria-NavigationTreeItem'); + } + + let groups = getAllByRole('rowgroup'); + expect(groups).toHaveLength(2); + expect(groups[0]).toHaveClass('react-aria-NavigationTreeSection'); + expect(groups[1]).toHaveClass('react-aria-NavigationTreeSection'); + + let header = rows[1]; + expect(header).toHaveClass('react-aria-NavigationTreeHeader'); + expect(within(header).getByRole('rowheader')).toHaveTextContent('Section 2'); + }); + + it('should support custom classes on NavigationTree and NavigationTreeItem', () => { + let {getByRole} = render( + + + + Your files + + + + ); + expect(getByRole('treegrid')).toHaveClass('test-NavigationTree'); + expect(getByRole('row')).toHaveClass('test-row'); + }); + + it('should support DOM props on NavigationTree and NavigationTreeItem', () => { + let {getByRole} = render( + + + + Your files + + + + ); + expect(getByRole('treegrid')).toHaveAttribute('data-testid', 'test-NavigationTree'); + expect(getByRole('row')).toHaveAttribute('data-testid', 'test-row'); + }); + + it('should support style on NavigationTree', () => { + let {getByRole} = render(); + expect(getByRole('treegrid')).toHaveAttribute('style', expect.stringContaining('width: 200px')); + }); + + it('should have the base set of data attributes', () => { + let {getByRole, getAllByRole} = render(); + let NavigationTree = getByRole('treegrid'); + expect(NavigationTree).toHaveAttribute('data-rac'); + expect(NavigationTree).not.toHaveAttribute('data-empty'); + expect(NavigationTree).not.toHaveAttribute('data-focused'); + expect(NavigationTree).not.toHaveAttribute('data-focus-visible'); + + for (let row of getAllByRole('row')) { + expect(row).toHaveAttribute('data-rac'); + expect(row).toHaveAttribute('data-level'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).not.toHaveAttribute('data-disabled'); + expect(row).not.toHaveAttribute('data-hovered'); + expect(row).not.toHaveAttribute('data-focused'); + expect(row).not.toHaveAttribute('data-focus-visible'); + expect(row).not.toHaveAttribute('data-pressed'); + expect(row).not.toHaveAttribute('data-selection-mode'); + expect(row).not.toHaveAttribute('data-current'); + } + }); + + it('should set data-current, data-expanded, data-has-child-items, data-level, and data-current-ancestor', () => { + let {getByRole} = render( + + ); + let filesRow = getByRole('row', {name: 'Your files'}); + let librariesRow = getByRole('row', {name: 'Your libraries'}); + let projects1Row = getByRole('row', {name: 'Projects 1'}); + let projects1ARow = getByRole('row', {name: 'Projects 1A'}); + + expect(projects1Row).toHaveAttribute('data-current', 'true'); + expect(filesRow).not.toHaveAttribute('data-current'); + expect(librariesRow).not.toHaveAttribute('data-current'); + expect(projects1ARow).not.toHaveAttribute('data-current'); + + expect(librariesRow).toHaveAttribute('data-expanded', 'true'); + expect(librariesRow).toHaveAttribute('data-has-child-items', 'true'); + expect(projects1Row).toHaveAttribute('data-expanded', 'true'); + expect(projects1Row).toHaveAttribute('data-has-child-items', 'true'); + expect(projects1ARow).not.toHaveAttribute('data-expanded'); + expect(projects1ARow).not.toHaveAttribute('data-has-child-items'); + + expect(filesRow).toHaveAttribute('data-level', '1'); + expect(librariesRow).toHaveAttribute('data-level', '1'); + expect(projects1Row).toHaveAttribute('data-level', '2'); + expect(projects1ARow).toHaveAttribute('data-level', '3'); + + expect(librariesRow).toHaveAttribute('data-current-ancestor', 'true'); + expect(filesRow).not.toHaveAttribute('data-current-ancestor'); + expect(projects1Row).not.toHaveAttribute('data-current-ancestor'); + expect(projects1ARow).not.toHaveAttribute('data-current-ancestor'); + }); + + it('sets data-focus-visible (and isFocusVisible) on the row for the link, not other children', async () => { + let {getByRole} = render( + + (isFocusVisible ? 'ring' : 'no-ring')}> + + Your files + + + + + ); + let row = getByRole('row', {name: /Your files/}); + let link = getByRole('link', {name: 'Your files'}); + let other = getByRole('button', {name: 'Other'}); + + expect(row).not.toHaveAttribute('data-focus-visible'); + + await user.tab(); + expect(link).toHaveFocus(); + expect(row).toHaveAttribute('data-focus-visible', 'true'); + expect(row).toHaveClass('ring'); + + // Tabbing to the other button keeps focus within the row, but focus-visible does not follow it. + await user.tab(); + expect(other).toHaveFocus(); + expect(row).not.toHaveAttribute('data-focus-visible'); + expect(row).toHaveClass('no-ring'); + }); + + it('exposes isCurrent on the NavigationTreeItem className render props', () => { + let {getByRole} = render( + + (isCurrent ? 'current' : 'not-current')}> + + Your files + + + (isCurrent ? 'current' : 'not-current')}> + + Your libraries + + + + ); + expect(getByRole('row', {name: 'Your files'})).toHaveClass('current'); + expect(getByRole('row', {name: 'Your files'})).toHaveAttribute('data-current', 'true'); + expect(getByRole('row', {name: 'Your libraries'})).toHaveClass('not-current'); + expect(getByRole('row', {name: 'Your libraries'})).not.toHaveAttribute('data-current'); + }); +}); diff --git a/starters/docs/src/NavigationTree.css b/starters/docs/src/NavigationTree.css new file mode 100644 index 00000000000..ba736fe1e9c --- /dev/null +++ b/starters/docs/src/NavigationTree.css @@ -0,0 +1,158 @@ +@import './theme.css'; + +.react-aria-NavigationTree { + display: flex; + flex-direction: column; + overflow: auto; + padding: var(--spacing-1); + border: 0.5px solid var(--border-color); + border-radius: calc(var(--radius) + var(--spacing-1)); + background: var(--overlay-background); + forced-color-adjust: none; + outline: none; + width: 250px; + max-height: 300px; + box-sizing: border-box; + + &[data-focus-visible] { + outline: 2px solid var(--focus-ring-color); + outline-offset: -1px; + } + + .react-aria-NavigationTreeSection:not(:first-child) { + margin-top: var(--spacing-4); + } + + .react-aria-NavigationTreeHeader { + font-size: var(--font-size-sm); + font-weight: 600; + padding: var(--spacing-1) var(--spacing-2); + color: var(--text-color); + } +} + +.react-aria-NavigationTreeItem { + --padding: var(--spacing-4); + display: flex; + position: relative; + align-items: center; + min-height: var(--spacing-8); + border-radius: var(--radius); + box-sizing: border-box; + outline: none; + color: var(--text-color); + font: var(--font-size) system-ui; + /* Indent each nested level. --tree-item-level is set on the row by React Aria (1-based). */ + padding-inline-start: calc((var(--tree-item-level, 1) - 1) * var(--padding)); + + /* The hover indicator lights up for any interactable row (links and expandable categories), + * matching React Aria's data-hovered, which is only set on actionable rows. */ + &[data-hovered] a.react-aria-Link:before { + content: ''; + position: absolute; + inset-inline-start: 2px; + top: 50%; + width: 4px; + transform: translateY(-50%); + height: 1lh; + background: var(--text-color-hover); + font-weight: 600; + border-radius: 9999px; + } + + /* When a current-route ancestor is collapsed, tint it and show a small dot where the pill would be. + * The dot (rather than the full pill) signals "the current route is nested inside here" without + * looking selected, and gives a non-color affordance. Higher specificity than the hover rule above, + * so the dot wins over the hover pill on these rows. Targets .react-aria-Link so it works whether the + * row renders as a link or a plain span. */ + &[data-current-ancestor]:not([data-expanded]) { + .react-aria-Link:before { + content: ''; + position: absolute; + inset-inline-start: 2px; + top: 50%; + width: 4px; + height: 4px; + transform: translateY(-50%); + background: var(--text-color-hover); + border-radius: 9999px; + } + } + + /* React Aria sets data-current on the link matching the NavigationTree's selectedRoute. */ + &[data-current]:not([data-hovered]) .react-aria-Link:before { + content: ''; + position: absolute; + inset-inline-start: 2px; + top: 50%; + width: 4px; + transform: translateY(-50%); + height: 1lh; + background: var(--highlight-background); + font-weight: 600; + border-radius: 9999px; + } + + &[data-focus-visible] { + outline: 2px solid var(--focus-ring-color); + outline-offset: -2px; + } + + &[data-disabled] { + color: var(--text-color-disabled); + } + + .react-aria-Link { + position: relative; + flex: 1; + display: flex; + align-items: center; + gap: var(--spacing-2); + min-width: 0; + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius); + color: inherit; + text-decoration: none; + outline: none; + cursor: pointer; + } + + /* A row without an href renders its label as a non-interactive span. */ + &:not([data-href]) .react-aria-Link { + cursor: default; + } + + .react-aria-Button[slot='chevron'] { + all: unset; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: var(--spacing-6); + height: var(--spacing-6); + border-radius: var(--radius); + cursor: default; + -webkit-tap-highlight-color: transparent; + + svg { + width: var(--spacing-4); + height: var(--spacing-4); + rotate: 0deg; + transition: rotate 200ms; + } + + &[data-focus-visible] { + outline: 2px solid var(--focus-ring-color); + } + } + + &[data-expanded] .react-aria-Button[slot='chevron'] svg { + rotate: 90deg; + } +} + +@media (forced-colors: active) { + .react-aria-NavigationTreeItem a.react-aria-Link { + color: LinkText; + } +} diff --git a/starters/docs/src/NavigationTree.tsx b/starters/docs/src/NavigationTree.tsx new file mode 100644 index 00000000000..7d59ed8f320 --- /dev/null +++ b/starters/docs/src/NavigationTree.tsx @@ -0,0 +1,70 @@ +'use client'; +import { + Button, + Link, + NavigationTree as AriaNavigationTree, + NavigationTreeHeader as AriaNavigationTreeHeader, + type NavigationTreeHeaderProps, + NavigationTreeItem as AriaNavigationTreeItem, + NavigationTreeItemContent as AriaNavigationTreeItemContent, + type NavigationTreeItemContentRenderProps, + type NavigationTreeItemProps as AriaNavigationTreeItemProps, + type NavigationTreeProps, + NavigationTreeSection as AriaNavigationTreeSection, + type NavigationTreeSectionProps +} from 'react-aria-components/NavigationTree'; +import {ChevronRight} from 'lucide-react'; +import React from 'react'; +import './NavigationTree.css'; + +export function NavigationTree(props: NavigationTreeProps) { + return ; +} + +export function NavigationTreeItemContent(props: {children?: React.ReactNode}) { + return ( + + {({hasChildItems}: NavigationTreeItemContentRenderProps) => ( + <> + {/* The label is rendered as a Link so it becomes the row's focusable child. It picks up + * href + aria-current automatically from the NavigationTree. Rows without an href render as a + * span instead of an anchor. */} + {props.children} + {hasChildItems && ( + + )} + + )} + + ); +} + +export interface NavigationTreeItemProps extends Partial { + title?: React.ReactNode; +} + +export function NavigationTreeItem(props: NavigationTreeItemProps) { + let textValue = typeof props.title === 'string' ? props.title : ''; + return ( + + {props.title != null ? ( + <> + {props.title} + {props.children} + + ) : ( + props.children + )} + + ); +} + +export function NavigationTreeSection(props: NavigationTreeSectionProps) { + return ; +} + +export function NavigationTreeHeader(props: NavigationTreeHeaderProps) { + return ; +} diff --git a/starters/tailwind/src/NavigationTree.tsx b/starters/tailwind/src/NavigationTree.tsx new file mode 100644 index 00000000000..76f390a9f44 --- /dev/null +++ b/starters/tailwind/src/NavigationTree.tsx @@ -0,0 +1,139 @@ +'use client'; +import { + Button, + Link, + NavigationTree as AriaNavigationTree, + NavigationTreeHeader as AriaNavigationTreeHeader, + type NavigationTreeHeaderProps, + NavigationTreeItem as AriaNavigationTreeItem, + NavigationTreeItemContent as AriaNavigationTreeItemContent, + type NavigationTreeItemProps as AriaNavigationTreeItemProps, + type NavigationTreeProps, + NavigationTreeSection as AriaNavigationTreeSection, + type NavigationTreeSectionProps +} from 'react-aria-components/NavigationTree'; +import {ChevronRight} from 'lucide-react'; +import React from 'react'; +import {tv} from 'tailwind-variants'; +import {composeTailwindRenderProps, focusRing} from './utils'; + +export function NavigationTree({children, ...props}: NavigationTreeProps) { + return ( + + {children} + + ); +} + +// The focus ring lives on the row (not the link) so it spans the whole item. Hover/current/ancestor +// state is surfaced as a leading-edge indicator on the Link (see linkStyles) rather than a full-row +// background. The row is a `group` so the Link can react to the row's data-* attributes. isFocusVisible +// comes from the render props; RAC's isFocusVisible already follows the link (it is not true when +// another child, e.g. a button, is focused). +const itemStyles = tv({ + extend: focusRing, + base: 'group relative font-sans flex items-center rounded-md cursor-default select-none text-neutral-800 dark:text-neutral-200 -outline-offset-2', + variants: { + isDisabled: { + true: 'text-neutral-300 dark:text-neutral-600 forced-colors:text-[GrayText]' + } + } +}); + +// A single `before` pseudo-element on the Link is the leading-edge indicator; the row's data-* +// attributes (via `group-[...]`) decide how it looks so the three states never overlap: +// - Hover pill (neutral, full height): only rows that render as a link (`data-href`) light up on +// hover, matching RAC's data-hovered on actionable rows. +// - Current pill (blue, full height): the selected row, but only when it is not also hovered, so +// hovering the selected row shows the neutral hover pill instead (never both). +// - Ancestor dot (neutral, short): a collapsed ancestor of the current route shows a small dot in +// the same spot. It shortens the height (higher specificity than the base) and shares the neutral +// color with hover, so hovering a collapsed ancestor still reads as the dot, not a full pill. +// Works for ancestors that render as a link or a plain span since it keys off the row, not the element. +const linkStyles = tv({ + base: + 'relative flex-1 min-w-0 flex items-center gap-2 py-1.5 px-2 text-sm no-underline text-current cursor-pointer outline-none ' + + // A row without an href renders its label as a non-interactive span, so it should not look clickable. + 'group-[:not([data-href])]:cursor-default ' + + "before:content-[''] before:absolute before:start-0.5 before:top-1/2 before:h-[1lh] before:w-1 before:-translate-y-1/2 before:rounded-full before:forced-color-adjust-none " + + 'group-[[data-hovered][data-href]]:before:bg-neutral-400 dark:group-[[data-hovered][data-href]]:before:bg-neutral-500 ' + + 'group-[[data-current]:not([data-hovered])]:before:bg-blue-600 dark:group-[[data-current]:not([data-hovered])]:before:bg-blue-400 ' + + 'group-[[data-current-ancestor]:not([data-expanded])]:before:h-1 group-[[data-current-ancestor]:not([data-expanded])]:before:bg-neutral-400 dark:group-[[data-current-ancestor]:not([data-expanded])]:before:bg-neutral-500 ' + + // In forced-colors mode authored backgrounds are dropped, so whenever the indicator is showing + // (any of the three states above) render it as the system Highlight color instead. + 'forced-colors:group-[:is([data-hovered][data-href],[data-current]:not([data-hovered]),[data-current-ancestor]:not([data-expanded]))]:before:bg-[Highlight]', + variants: { + isDisabled: { + true: 'cursor-default' + } + } +}); + +const expandButton = tv({ + extend: focusRing, + base: 'shrink-0 w-6 h-6 flex items-center justify-center rounded-md border-0 p-0 bg-transparent cursor-default [-webkit-tap-highlight-color:transparent] -outline-offset-2' +}); + +const chevron = tv({ + base: 'w-4 h-4 text-neutral-500 dark:text-neutral-400 transition-transform duration-200 ease-in-out', + variants: { + isExpanded: { + true: 'rotate-90' + } + } +}); + +export function NavigationTreeItemContent(props: {children?: React.ReactNode}) { + return ( + + {({level, hasChildItems, isDisabled, isExpanded}) => ( + <> + {level > 1 && ( +
+ )} + {props.children} + {hasChildItems && ( + + )} + + )} + + ); +} + +export interface NavigationTreeItemProps extends Partial { + title?: React.ReactNode; +} + +export function NavigationTreeItem(props: NavigationTreeItemProps) { + let textValue = typeof props.title === 'string' ? props.title : ''; + return ( + + {props.title} + {props.children} + + ); +} + +export function NavigationTreeSection(props: NavigationTreeSectionProps) { + return ; +} + +export function NavigationTreeHeader(props: NavigationTreeHeaderProps) { + return ( + + ); +}