From b374cbea982f28e5ec4ffb9bff8cef4f304f04cc Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 3 Nov 2025 11:27:49 -0800 Subject: [PATCH 1/8] Fix ZIP/CodeSandbox/Stackblitz for all examples --- .../dev/s2-docs/pages/react-aria/Calendar.mdx | 16 +- .../dev/s2-docs/pages/react-aria/DropZone.mdx | 1 + .../pages/react-aria/RangeCalendar.mdx | 16 +- packages/dev/s2-docs/pages/s2/Calendar.mdx | 16 +- packages/dev/s2-docs/pages/s2/Menu.mdx | 2 +- .../dev/s2-docs/pages/s2/RangeCalendar.mdx | 16 +- packages/dev/s2-docs/pages/s2/Skeleton.mdx | 2 +- packages/dev/s2-docs/src/CodeBlock.tsx | 193 ++++++++++++------ packages/dev/s2-docs/src/CodeFold.tsx | 2 +- packages/dev/s2-docs/src/CodePlatter.tsx | 111 ++++++---- packages/dev/s2-docs/src/CodeSandbox.tsx | 50 ++--- packages/dev/s2-docs/src/ExampleApp.tsx | 17 +- packages/dev/s2-docs/src/StackBlitz.tsx | 30 +-- packages/dev/s2-docs/src/VisualExample.tsx | 37 ++-- .../dev/s2-docs/src/VisualExampleClient.tsx | 52 ++--- packages/react-aria-components/src/Tabs.tsx | 2 +- 16 files changed, 352 insertions(+), 211 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/Calendar.mdx b/packages/dev/s2-docs/pages/react-aria/Calendar.mdx index 4325cdc604c..37de09881a0 100644 --- a/packages/dev/s2-docs/pages/react-aria/Calendar.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Calendar.mdx @@ -85,13 +85,15 @@ import type {AnyCalendarDate} from '@internationalized/date'; import {CalendarDate, startOfWeek, toCalendar, GregorianCalendar} from '@internationalized/date'; import {Calendar} from 'vanilla-starter/Calendar'; -export default ( - new Custom454()} /> - ///- end highlight -/// -); +export default function Example() { + return ( + new Custom454()} /> + ///- end highlight -/// + ); +} // See @internationalized/date docs linked above. ///- begin collapse -/// diff --git a/packages/dev/s2-docs/pages/react-aria/DropZone.mdx b/packages/dev/s2-docs/pages/react-aria/DropZone.mdx index bbba85ff6c2..74d53895a07 100644 --- a/packages/dev/s2-docs/pages/react-aria/DropZone.mdx +++ b/packages/dev/s2-docs/pages/react-aria/DropZone.mdx @@ -86,6 +86,7 @@ export const tags = ['file', 'drag', 'dnd', 'upload']; ); + } ``` diff --git a/packages/dev/s2-docs/pages/react-aria/RangeCalendar.mdx b/packages/dev/s2-docs/pages/react-aria/RangeCalendar.mdx index 7d746d4392b..b189c1b42d1 100644 --- a/packages/dev/s2-docs/pages/react-aria/RangeCalendar.mdx +++ b/packages/dev/s2-docs/pages/react-aria/RangeCalendar.mdx @@ -95,13 +95,15 @@ import type {AnyCalendarDate} from '@internationalized/date'; import {CalendarDate, startOfWeek, toCalendar, GregorianCalendar} from '@internationalized/date'; import {RangeCalendar} from 'vanilla-starter/RangeCalendar'; -export default ( - new Custom454()} /> - ///- end highlight -/// -); +export default function Example() { + return ( + new Custom454()} /> + ///- end highlight -/// + ); +} // See @internationalized/date docs linked above. ///- begin collapse -/// diff --git a/packages/dev/s2-docs/pages/s2/Calendar.mdx b/packages/dev/s2-docs/pages/s2/Calendar.mdx index 23ec382c20c..d06db8c2ffb 100644 --- a/packages/dev/s2-docs/pages/s2/Calendar.mdx +++ b/packages/dev/s2-docs/pages/s2/Calendar.mdx @@ -77,13 +77,15 @@ import type {AnyCalendarDate} from '@internationalized/date'; import {CalendarDate, startOfWeek, toCalendar, GregorianCalendar} from '@internationalized/date'; import {Calendar} from '@react-spectrum/s2'; -export default ( - new Custom454()} /> - ///- end highlight -/// -); +export default function Example() { + return ( + new Custom454()} /> + ///- end highlight -/// + ); +} // See @internationalized/date docs linked above. ///- begin collapse -/// diff --git a/packages/dev/s2-docs/pages/s2/Menu.mdx b/packages/dev/s2-docs/pages/s2/Menu.mdx index 248d3471389..52a030e27bb 100644 --- a/packages/dev/s2-docs/pages/s2/Menu.mdx +++ b/packages/dev/s2-docs/pages/s2/Menu.mdx @@ -143,7 +143,7 @@ import Paste from '@react-spectrum/s2/icons/Paste'; ``` -```tsx render +```tsx render type="s2" "use client"; import {MenuTrigger, ActionButton, Menu, MenuItem, Text, Image} from '@react-spectrum/s2'; import normal from 'url:./assets/normal.png'; diff --git a/packages/dev/s2-docs/pages/s2/RangeCalendar.mdx b/packages/dev/s2-docs/pages/s2/RangeCalendar.mdx index 519bac96393..a9df14504c0 100644 --- a/packages/dev/s2-docs/pages/s2/RangeCalendar.mdx +++ b/packages/dev/s2-docs/pages/s2/RangeCalendar.mdx @@ -88,13 +88,15 @@ import type {AnyCalendarDate} from '@internationalized/date'; import {CalendarDate, startOfWeek, toCalendar, GregorianCalendar} from '@internationalized/date'; import {RangeCalendar} from '@react-spectrum/s2'; -export default ( - new Custom454()} /> - ///- end highlight -/// -); +export default function Example() { + return ( + new Custom454()} /> + ///- end highlight -/// + ); +} // See @internationalized/date docs linked above. ///- begin collapse -/// diff --git a/packages/dev/s2-docs/pages/s2/Skeleton.mdx b/packages/dev/s2-docs/pages/s2/Skeleton.mdx index 2f20dcbcde1..e82018cb1af 100644 --- a/packages/dev/s2-docs/pages/s2/Skeleton.mdx +++ b/packages/dev/s2-docs/pages/s2/Skeleton.mdx @@ -9,7 +9,7 @@ export const tags = ['loading', 'placeholder', 'shimmer', 'ghost']; {docs.exports.Skeleton.description} -```tsx render docs={docs.exports.Skeleton} links={docs.links} props={['isLoading']} initialProps={{isLoading: true}} +```tsx render docs={docs.exports.Skeleton} links={docs.links} props={['isLoading']} initialProps={{isLoading: true}} type="s2" "use client"; import {Skeleton, Image, Heading, Text} from '@react-spectrum/s2' import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; diff --git a/packages/dev/s2-docs/src/CodeBlock.tsx b/packages/dev/s2-docs/src/CodeBlock.tsx index b8f766ecc9b..78cde6de64e 100644 --- a/packages/dev/s2-docs/src/CodeBlock.tsx +++ b/packages/dev/s2-docs/src/CodeBlock.tsx @@ -1,11 +1,14 @@ +// @ts-ignore +import assets from 'url:../pages/**/*.{png,jpg,svg}'; +import {cache, ReactNode} from 'react'; import {Code, ICodeProps} from './Code'; -import {CodePlatter, Pre} from './CodePlatter'; +import {CodePlatter, FileProvider, Pre} from './CodePlatter'; import {ExampleOutput} from './ExampleOutput'; import {ExpandableCode} from './ExpandableCode'; +import {findPackageJSON} from 'module'; import fs from 'fs'; import {highlight, Language} from 'tree-sitter-highlight'; import path from 'path'; -import {ReactNode} from 'react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Tab, TabList, TabPanel, Tabs} from '@react-spectrum/s2'; import {VisualExample, VisualExampleProps} from './VisualExample'; @@ -52,32 +55,35 @@ const standaloneCode = style({ interface CodeBlockProps extends VisualExampleProps { render?: ReactNode, children: string, + dir?: string, files?: string[], expanded?: boolean, hidden?: boolean, - hideExampleCode?: boolean, includeAllImports?: boolean, showCoachMark?: boolean } -export function CodeBlock({render, children, files, expanded, hidden, hideExampleCode, includeAllImports, ...props}: CodeBlockProps) { +export function CodeBlock({render, children, dir, files, expanded, hidden, includeAllImports, ...props}: CodeBlockProps) { if (hidden) { return null; } - children = children.replace(/(vanilla-starter|tailwind-starter)\//g, './'); + let displayCode = children.replace(/(vanilla-starter|tailwind-starter)\//g, './'); if (!render) { return (
-        {children}
+        {displayCode}
       
); } + let resolveFrom = path.resolve('pages', dir || (props.type === 's2' ? 's2' : 'react-aria'), 'index.tsx'); + let downloadFiles = getExampleFiles(resolveFrom, children, props.type); + let code = ( - {children} + {displayCode} ); @@ -87,17 +93,19 @@ export function CodeBlock({render, children, files, expanded, hidden, hideExampl {...props} component={render} files={files} + downloadFiles={downloadFiles} code={code} /> ); } - let content = hideExampleCode ? null : ( - - {code} - + let content = ( + + + {code} + + ); return ( @@ -106,9 +114,14 @@ export function CodeBlock({render, children, files, expanded, hidden, hideExampl component={render} align={props.align} />
- {files - ? {content} - : content} + {files ? + + {content} + + : content}
); @@ -148,84 +161,148 @@ function TruncatedCode({children, maxLines = 6, ...props}: TruncatedCodeProps) { ); } -export function Files({children, files, defaultSelected, maxLines}: {children?: ReactNode, files: string[], defaultSelected?: string, maxLines?: number}) { - let allFiles = getFiles(files); +export function Files({children, files, type, defaultSelected, maxLines}: {children?: ReactNode, files: string[], type?: 'vanilla' | 'tailwind' | 's2', defaultSelected?: string, maxLines?: number}) { return ( + density="compact" + data-files> {children && Example} {files.map(file => {path.basename(file)})} - {children && {children}} - {files.map(file => )} + {children && {children}} + {files.map(file => )} ); } -export function File({filename, maxLines, files}: {filename: string, maxLines?: number, files?: {[name: string]: string}}) { - let contents = fs.readFileSync(path.isAbsolute(filename) ? filename : '../../../' + filename, 'utf8').replace(/(vanilla-starter|tailwind-starter)\//g, './'); +const readFile = cache((file: string) => fs.readFileSync(file, 'utf8')); + +export function File({filename, maxLines, type}: {filename: string, maxLines?: number, type?: 'vanilla' | 'tailwind' | 's2'}) { + let contents = readFile(path.isAbsolute(filename) ? filename : path.resolve('../../../', filename)).replace(/(vanilla-starter|tailwind-starter)\//g, './'); return ( - + {contents} ); } // Reads files, parses imports, and loads recursively. -export function getFiles(files: string[]) { - files = findAllFiles(files); +export function getFiles(files: string[], type: string | undefined, npmDeps = {}) { let fileContents = {}; - for (let file of files) { + for (let file of findAllFiles(files, npmDeps)) { let name = path.basename(file); - let contents = fs.readFileSync(file, 'utf8'); - fileContents[name] = contents.replace(/(vanilla-starter|tailwind-starter)\//g, './'); + let contents = readFile(file); + fileContents[name] = contents + .replace(/(vanilla-starter|tailwind-starter)\//g, './') + .replace(/import (.*?) from ['"]url:(.*?)['"]/g, (_, name, specifier) => { + return `const ${name} = '${resolveUrl(specifier, file)}'`; + }); + } + + if (type === 'tailwind' && !fileContents['index.css']) { + fileContents['index.css'] = readFile(path.resolve('../../../starters/tailwind/src/index.css')); } - return fileContents; + return {files: fileContents, deps: npmDeps}; } -function findAllFiles(files: string[]) { +function findAllFiles(files: string[], npmDeps = {}) { + files = files.map(file => path.isAbsolute(file) ? file : path.resolve('../../../', file)); + let queue: string[] = [...files]; - let allFiles = new Map(); + let allFiles = new Set(); for (let i = 0; i < queue.length; i++) { - let file = path.isAbsolute(queue[i]) ? queue[i] : path.resolve('../../../' + queue[i]); - if (path.extname(file) === '') { - if (fs.existsSync(file + '.tsx')) { - file += '.tsx'; - } else if (fs.existsSync(file + '.ts')) { - file += '.ts'; + let file = queue[i]; + let contents = readFile(file); + allFiles.add(file); + + let deps = parseFile(file, contents, npmDeps); + for (let dep of deps) { + if (!allFiles.has(dep)) { + queue.push(dep); } } + } - let name = path.basename(file); - let contents = fs.readFileSync(file, 'utf8'); - allFiles.set(name, file); + let addedFiles = [...allFiles.values()].filter(f => !files.includes(f)).sort(); + return [...files, ...addedFiles]; +} - for (let [, specifier] of contents.matchAll(/import(?:.|\n)+?['"](.+)['"]/g)) { - specifier = specifier.replace(/(vanilla-starter|tailwind-starter)\//g, (m, s) => 'starters/' + (s === 'vanilla-starter' ? 'docs' : 'tailwind') + '/src/'); - if (!/^(\.|starters)/.test(specifier)) { - continue; - } +function parseFile(file: string, contents: string, npmDeps = {}, urls = {}) { + let deps = new Set(); + for (let [, specifier] of contents.matchAll(/import (?:.|\n)*?['"](.+?)['"]/g)) { + specifier = specifier.replace(/(vanilla-starter|tailwind-starter)\//g, (m, s) => 'starters/' + (s === 'vanilla-starter' ? 'docs' : 'tailwind') + '/src/'); + + if (specifier.startsWith('url:')) { + urls[specifier] = resolveUrl(specifier.slice(4), file); + continue; + } + + if (!/^(\.|starters)/.test(specifier)) { + let dep = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]; + npmDeps[dep] ??= '^' + getPackageVersion(dep); + continue; + } - let resolved = specifier.startsWith('.') ? path.resolve(path.dirname(file), specifier) : specifier; - if (!allFiles.has(path.basename(resolved))) { - queue.push(resolved); + let resolved = specifier.startsWith('.') ? path.resolve(path.dirname(file), specifier) : path.resolve('../../../' + specifier); + if (path.extname(resolved) === '') { + if (fs.existsSync(resolved + '.tsx')) { + resolved += '.tsx'; + } else if (fs.existsSync(resolved + '.ts')) { + resolved += '.ts'; } } + + deps.add(resolved); } - let providedFiles = files.map(f => { - let name = path.basename(f); - let res = allFiles.get(name)!; - allFiles.delete(name); - return res; - }); + return deps; +} + +function getExampleFiles(file: string, contents: string, type: string | undefined) { + let npmDeps = {}; + let urls = {}; + let fileDeps = parseFile(file, contents, npmDeps, urls); + let {files} = getFiles([...fileDeps], type, npmDeps); + return {files, deps: npmDeps, urls}; +} - let addedFiles = [...allFiles.values()].sort(); +let packageVersionCache = new Map(); +function getPackageVersion(pkg: string) { + let version = packageVersionCache.get(pkg); + if (version) { + return version; + } + + let p = findPackageJSON(pkg, __filename); + if (p) { + let json = JSON.parse(fs.readFileSync(p, 'utf8')); + packageVersionCache.set(pkg, json.version); + return json.version; + } else { + throw new Error('Could not find package.json for ' + pkg); + } +} + +function resolveUrl(specifier: string, file: string) { + let relative = path.relative(path.resolve('pages'), path.dirname(file)).split(/[/\\]/); + let cur = assets; + for (let part of [...relative, ...specifier.slice(2).split('/')]) { + let p = part.split('.'); + cur = cur[p[0]]; + if (!cur) { + throw new Error('Could not resolve URL ' + specifier); + } + + if (p[1]) { + cur = cur[p[1]]; + } + } - return [...providedFiles, ...addedFiles]; + let publicUrl = process.env.PUBLIC_URL || 'http://localhost:1234'; + return publicUrl + cur; } diff --git a/packages/dev/s2-docs/src/CodeFold.tsx b/packages/dev/s2-docs/src/CodeFold.tsx index e1441445739..befa0424a86 100644 --- a/packages/dev/s2-docs/src/CodeFold.tsx +++ b/packages/dev/s2-docs/src/CodeFold.tsx @@ -43,7 +43,7 @@ const more = style({ height: 16, display: 'inline-flex', alignItems: 'center', - verticalAlign: 'top', + verticalAlign: 'middle', userSelect: 'none', '--iconPrimary': { type: 'fill', diff --git a/packages/dev/s2-docs/src/CodePlatter.tsx b/packages/dev/s2-docs/src/CodePlatter.tsx index 6369f95b2bd..7e4be911fbd 100644 --- a/packages/dev/s2-docs/src/CodePlatter.tsx +++ b/packages/dev/s2-docs/src/CodePlatter.tsx @@ -11,7 +11,7 @@ import LinkIcon from '@react-spectrum/s2/icons/Link'; import OpenIn from '@react-spectrum/s2/icons/OpenIn'; import Polygon4 from '@react-spectrum/s2/icons/Polygon4'; import Prompt from '@react-spectrum/s2/icons/Prompt'; -import React, {createContext, ReactNode, useContext, useRef, useState} from 'react'; +import React, {createContext, ProviderProps, ReactNode, RefObject, useContext, useRef, useState} from 'react'; import {ShadcnCommand} from './ShadcnCommand'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {zip} from './zip'; @@ -36,10 +36,7 @@ const platterStyle = style({ interface CodePlatterProps { children: ReactNode, - shareUrl?: string, - files?: {[name: string]: string}, type?: 'vanilla' | 'tailwind' | 's2', - registryUrl?: string, showCoachMark?: boolean } @@ -52,7 +49,29 @@ export function CodePlatterProvider(props: CodePlatterContextValue & {children: return {props.children}; } -export function CodePlatter({children, shareUrl, files, type, registryUrl, showCoachMark}: CodePlatterProps) { +interface FileProviderContextValue { + files?: {[name: string]: string}, + deps?: {[name: string]: string}, + urls?: {[url: string]: string}, + entry?: string +} + +const FileProviderContext = createContext(null); +export function FileProvider(props: ProviderProps) { + return ; +} + +const ShadcnContext = createContext(null); +export function ShadcnProvider(props: ProviderProps) { + return ; +} + +const ShareContext = createContext(null); +export function ShareUrlProvider(props: ProviderProps) { + return ; +} + +export function CodePlatter({children, type, showCoachMark}: CodePlatterProps) { let codeRef = useRef(null); let [showShadcn, setShowShadcn] = useState(false); let getText = () => codeRef.current!.querySelector('pre')!.textContent!; @@ -65,6 +84,10 @@ export function CodePlatter({children, shareUrl, files, type, registryUrl, showC } } + let {files, deps = {}, urls = {}, entry} = useContext(FileProviderContext) ?? {}; + let registryUrl = useContext(ShadcnContext); + let shareUrl = useContext(ShareContext); + return (
@@ -74,7 +97,7 @@ export function CodePlatter({children, shareUrl, files, type, registryUrl, showC density="regular" size="S"> - {(shareUrl || files || type || registryUrl) && + {(shareUrl || files || registryUrl) && @@ -103,17 +126,13 @@ export function CodePlatter({children, shareUrl, files, type, registryUrl, showC Copy link } - {(files || type) && + {files && { - let code = codeRef.current!.querySelector('pre')!.textContent!; - let filesToDownload = getCodeSandboxFiles({ - ...files, - 'Example.tsx': transformExampleCode(code) - }, type); + let filesToDownload = getCodeSandboxFiles(getExampleFiles(codeRef, files, urls, entry), deps, type, entry); let filesToZip = {}; for (let key in filesToDownload) { - if (filesToDownload[key]) { + if (filesToDownload[key] && !key.startsWith('.codesandbox') && !key.startsWith('.devcontainer')) { filesToZip[key] = filesToDownload[key].content; } } @@ -138,27 +157,19 @@ export function CodePlatter({children, shareUrl, files, type, registryUrl, showC Install with shadcn } - {(files || type) && + {files && { - let code = codeRef.current!.querySelector('pre')!.textContent!; - createCodeSandbox({ - ...files, - 'Example.tsx': transformExampleCode(code) - }, type); + createCodeSandbox(getExampleFiles(codeRef, files, urls, entry), deps, type, entry); }}> Open in CodeSandbox } - {(files || type) && type !== 's2' && + {files && type !== 's2' && { - let code = codeRef.current!.querySelector('pre')!.textContent!; - createStackBlitz({ - ...files, - 'Example.tsx': transformExampleCode(code) - }, type); + createStackBlitz(getExampleFiles(codeRef, files, urls, entry), deps, type, entry); }}> Open in StackBlitz @@ -211,23 +222,47 @@ export function Pre({children}) { ); } -function transformExampleCode(code: string): string { - // Export the last function - code = code.replace(/\nfunction ([^(]+)((.|\n)+\n\}\n?)$/, '\nexport function Example$2'); +function getExampleFiles(codeRef: RefObject, files: {[name: string]: string}, urls: {[name: string]: string}, entry: string | undefined) { + if (!entry) { + return { + ...files, + 'Example.tsx': getExampleCode(codeRef, urls) + }; + } - // Add function wrapper around raw JSX in examples. - return code.replace(/\n<((?:.|\n)+)/, (_, code) => { - let res = '\nexport function Example() {\n return (\n <'; - let lines = code.split('\n'); - res += lines.shift(); + return files; +} - for (let line of lines) { - res += '\n ' + line; +function getExampleCode(codeRef: RefObject, urls: {[name: string]: string}) { + let code = codeRef.current!.querySelector('pre')!.textContent!; + let fileTabs = codeRef.current!.closest('[data-files]'); + if (fileTabs) { + let example = fileTabs.querySelector('[data-example]'); + if (example) { + code = example.textContent!; } + } + + return code + // Export the last function + .replace(/\nfunction ([^(]+)((.|\n)+\n\}\n?)$/, '\nexport default function Example$2') + // Add function wrapper around raw JSX in examples. + .replace(/\n<((?:.|\n)+)/, (_, code) => { + let res = '\nexport default function Example() {\n return (\n <'; + let lines = code.split('\n'); + res += lines.shift(); + + for (let line of lines) { + res += '\n ' + line; + } - res += '\n );\n}\n'; - return res; - }); + res += '\n );\n}\n'; + return res; + }) + // Resolve urls + .replace(/import (.*?) from ['"](url:.*?)['"]/g, (_, name, specifier) => { + return `const ${name} = '${urls[specifier]}'`; + }); } const V0 = createIcon(props => ( diff --git a/packages/dev/s2-docs/src/CodeSandbox.tsx b/packages/dev/s2-docs/src/CodeSandbox.tsx index be0b47c3204..9783bd0db39 100644 --- a/packages/dev/s2-docs/src/CodeSandbox.tsx +++ b/packages/dev/s2-docs/src/CodeSandbox.tsx @@ -1,6 +1,11 @@ import LZString from 'lz-string'; -export function createCodeSandbox(files: {[name: string]: string}, type: 'vanilla' | 'tailwind' | 's2' = 'vanilla') { +export function createCodeSandbox( + files: {[name: string]: string}, + deps: {[name: string]: string}, + type: 'vanilla' | 'tailwind' | 's2' = 'vanilla', + entry: string = 'Example' +) { let form = document.createElement('form'); form.hidden = true; form.method = 'POST'; @@ -24,7 +29,7 @@ export function createCodeSandbox(files: {[name: string]: string}, type: 'vanill input.name = 'parameters'; input.value = LZString.compressToBase64(JSON.stringify({ - files: getCodeSandboxFiles(files, type) + files: getCodeSandboxFiles(files, deps, type, entry) })); form.appendChild(input); @@ -33,29 +38,6 @@ export function createCodeSandbox(files: {[name: string]: string}, type: 'vanill form.remove(); } -const dependencies = { - vanilla: { - 'react-aria-components': '^1.10.0', - react: '^19', - 'react-dom': '^19', - 'lucide-react': '^0.514.0', - 'clsx': '^2.1.1' - }, - tailwind: { - 'react-aria-components': '^1.10.0', - react: '^19', - 'react-dom': '^19', - 'lucide-react': '^0.514.0', - 'tailwind-variants': '^0.3.1', - 'tailwind-merge': '^2.5.4' - }, - s2: { - '@react-spectrum/s2': 'latest', - react: '^19', - 'react-dom': '^19' - } -}; - const devDependencies = { vanilla: { '@types/react': '^19', @@ -82,7 +64,13 @@ const devDependencies = { } }; -export function getCodeSandboxFiles(files: {[name: string]: string}, type: 'vanilla' | 'tailwind' | 's2' = 'vanilla') { +export function getCodeSandboxFiles( + files: {[name: string]: string}, + deps: {[name: string]: string}, + type: 'vanilla' | 'tailwind' | 's2' = 'vanilla', + entry: string = 'Example' +) { + let entryName = entry.split('/').pop()!.split('.')[0]; return { '.codesandbox/tasks.json': { content: JSON.stringify({ @@ -125,7 +113,11 @@ export function getCodeSandboxFiles(files: {[name: string]: string}, type: 'vani start: 'parcel', build: 'parcel build' }, - dependencies: dependencies[type], + dependencies: { + react: '^19', + 'react-dom': '^19', + ...deps + }, devDependencies: devDependencies[type] }, null, 2) + '\n' }, @@ -153,9 +145,9 @@ export function getCodeSandboxFiles(files: {[name: string]: string}, type: 'vani }, 'src/index.tsx': { content: `import {createRoot} from 'react-dom/client'; -import {Example} from './Example';${type === 's2' ? "\nimport '@react-spectrum/s2/page.css';" : ''} +import ${entryName} from './${entryName}';${type === 's2' ? "\nimport '@react-spectrum/s2/page.css';" : ''} -createRoot(document.getElementById('root')!).render(); +createRoot(document.getElementById('root')!).render(<${entryName} />); ` }, 'tsconfig.json': { diff --git a/packages/dev/s2-docs/src/ExampleApp.tsx b/packages/dev/s2-docs/src/ExampleApp.tsx index e52256aae8f..3d813885942 100644 --- a/packages/dev/s2-docs/src/ExampleApp.tsx +++ b/packages/dev/s2-docs/src/ExampleApp.tsx @@ -1,13 +1,22 @@ -import {Files} from './CodeBlock'; +import {FileProvider} from './CodePlatter'; +import {Files, getFiles} from './CodeBlock'; import fs from 'fs/promises'; import path from 'path'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -export async function ExampleApp({dir, defaultSelected}: {dir: string, defaultSelected?: string}) { - let files = await fs.readdir('../../../' + dir); +export async function ExampleApp({dir, defaultSelected, type}: {dir: string, defaultSelected?: string, type?: 'tailwind' | 'vanilla' | 's2'}) { + let files = (await fs.readdir('../../../' + dir, {withFileTypes: true})).filter(d => d.isFile()).map(d => path.join(dir, d.name)); + let {files: downloadFiles, deps} = getFiles(files, type); + return (
- path.join(dir, f))} defaultSelected={defaultSelected ? path.join(dir, defaultSelected) : undefined} maxLines={Infinity} /> + + +
); } diff --git a/packages/dev/s2-docs/src/StackBlitz.tsx b/packages/dev/s2-docs/src/StackBlitz.tsx index 2bf40edad7e..524c85823e8 100644 --- a/packages/dev/s2-docs/src/StackBlitz.tsx +++ b/packages/dev/s2-docs/src/StackBlitz.tsx @@ -1,4 +1,9 @@ -export function createStackBlitz(files: {[name: string]: string}, type: 'vanilla' | 'tailwind' | 's2' = 'vanilla') { +export function createStackBlitz( + files: {[name: string]: string}, + deps: {[name: string]: string}, + type: 'vanilla' | 'tailwind' | 's2' = 'vanilla', + entry: string = 'Example' +) { let form = document.createElement('form'); form.hidden = true; form.method = 'POST'; @@ -23,7 +28,7 @@ export function createStackBlitz(files: {[name: string]: string}, type: 'vanilla input.value = 'description'; form.appendChild(input); - let generatedFiles = getFiles(files, type); + let generatedFiles = getFiles(files, deps, type, entry); for (let name in generatedFiles) { input = document.createElement('input'); input.type = 'hidden'; @@ -37,7 +42,13 @@ export function createStackBlitz(files: {[name: string]: string}, type: 'vanilla form.remove(); } -function getFiles(files: {[name: string]: string}, type: 'vanilla' | 'tailwind' | 's2' = 'vanilla') { +function getFiles( + files: {[name: string]: string}, + deps: {[name: string]: string}, + type: 'vanilla' | 'tailwind' | 's2' = 'vanilla', + entry: string = 'Example' +) { + let entryName = entry.split('/').pop()!.split('.')[0]; return { 'package.json': JSON.stringify({ name: 'react-aria-starter', @@ -50,13 +61,9 @@ function getFiles(files: {[name: string]: string}, type: 'vanilla' | 'tailwind' preview: 'vite preview' }, dependencies: { - 'react-aria-components': '^1.10.0', react: '^19', 'react-dom': '^19', - 'lucide-react': '^0.514.0', - ...(type === 'tailwind' ? { - 'tailwind-variants': '^0.3.1' - } : {}) + ...deps }, devDependencies: { '@types/react': '^19', @@ -90,11 +97,10 @@ export default defineConfig({ `, - 'src/index.tsx': `import {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import {Example} from './Example'; + 'src/index.tsx': `import {createRoot} from 'react-dom/client'; +import ${entryName} from './${entryName}'; -createRoot(document.getElementById('root')!).render(); +createRoot(document.getElementById('root')!).render(<${entryName} />); `, 'tsconfig.json': JSON.stringify({ compilerOptions: { diff --git a/packages/dev/s2-docs/src/VisualExample.tsx b/packages/dev/s2-docs/src/VisualExample.tsx index 5da3e6752f3..0f32c3f5eeb 100644 --- a/packages/dev/s2-docs/src/VisualExample.tsx +++ b/packages/dev/s2-docs/src/VisualExample.tsx @@ -1,4 +1,5 @@ import {CodeOutput, Control, Output, VisualExampleClient} from './VisualExampleClient'; +import {FileProvider, ShadcnProvider} from './CodePlatter'; import {Files, getFiles} from './CodeBlock'; import json5 from 'json5'; import path from 'path'; @@ -86,6 +87,7 @@ export interface VisualExampleProps { importSource?: string, /** When provided, the source code for the listed filenames will be included as tabs. */ files?: string[], + downloadFiles?: {files?: {[name: string]: string}, deps?: {[name: string]: string}}, type?: 'vanilla' | 'tailwind' | 's2', code?: ReactNode, wide?: boolean, @@ -106,7 +108,7 @@ export interface PropControl extends Omit { /** * Displays a component example with controls for changing the props. */ -export function VisualExample({component, docs, links, importSource, props, initialProps, controlOptions, files, code, wide, slots, align, acceptOrientation, type, propsObject, showCoachMark}: VisualExampleProps) { +export function VisualExample({component, docs, links, importSource, props, initialProps, controlOptions, files, downloadFiles, code, wide, slots, align, acceptOrientation, type, propsObject, showCoachMark}: VisualExampleProps) { let componentProps = docs.type === 'interface' ? docs : docs.props; if (componentProps?.type !== 'interface') { return null; @@ -156,27 +158,38 @@ export function VisualExample({component, docs, links, importSource, props, init importSource = './' + path.basename(files[0], path.extname(files[0])); } + if (!downloadFiles) { + if (files) { + downloadFiles = getFiles(files, type); + } else { + downloadFiles = {}; + } + } + + let registryUrl = type === 's2' || docs.type !== 'component' ? null : `${type}/${docs.name}.json`; let output = ( ); // Render the corresponding client component to make the controls interactive. return ( -
- -
- {Object.keys(controls).map(control => )} -
-
- {files ? {output} : output} -
-
+ + +
+ +
+ {Object.keys(controls).map(control => )} +
+
+ {files ? {output} : output} +
+
+
+
); } diff --git a/packages/dev/s2-docs/src/VisualExampleClient.tsx b/packages/dev/s2-docs/src/VisualExampleClient.tsx index c748e9545b9..f44ce3361ae 100644 --- a/packages/dev/s2-docs/src/VisualExampleClient.tsx +++ b/packages/dev/s2-docs/src/VisualExampleClient.tsx @@ -3,7 +3,7 @@ import {ActionButton, Avatar, Collection, ComboBox, ComboBoxItem, Content, ContextualHelp, Footer, Header, Heading, NotificationBadge, NumberField, Picker, PickerItem, PickerSection, RangeSlider, Slider, Switch, Text, TextField, ToggleButton, ToggleButtonGroup} from '@react-spectrum/s2'; import AddCircle from '@react-spectrum/s2/icons/AddCircle'; import {baseColor, focusRing, style, StyleString} from '@react-spectrum/s2/style' with { type: 'macro' }; -import {CodePlatter, Pre} from './CodePlatter'; +import {CodePlatter, Pre, ShareUrlProvider} from './CodePlatter'; import {ExampleOutput} from './ExampleOutput'; import {ExampleSwitcherContext} from './ExampleSwitcher'; import {flushSync} from 'react-dom'; @@ -96,10 +96,31 @@ export function VisualExampleClient({component, name, importSource, controls, ch // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + let searchParams = new URLSearchParams(); + let exampleType = useContext(ExampleSwitcherContext); + if (exampleType) { + searchParams.set('exampleType', String(exampleType)); + } + + for (let prop in props) { + let value = props[prop]; + if ( + value != null && + controls[prop] != null && + (controls[prop].default == null || value !== controls[prop].default) + ) { + searchParams.set(prop, JSON.stringify(value)); + } + } + + let url = '?' + searchParams.toString(); + return (