diff --git a/packages/@react-aria/tooltip/src/useTooltipTrigger.ts b/packages/@react-aria/tooltip/src/useTooltipTrigger.ts index d31c8ae9024..cacef8ec890 100644 --- a/packages/@react-aria/tooltip/src/useTooltipTrigger.ts +++ b/packages/@react-aria/tooltip/src/useTooltipTrigger.ts @@ -37,7 +37,7 @@ export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTrig let { isDisabled, trigger, - closeOnPress = true + shouldCloseOnPress = true } = props; let tooltipId = useId(); @@ -103,8 +103,8 @@ export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTrig }; let onPressStart = () => { - // if closeOnPress is false, we should not close the tooltip - if (!closeOnPress) { + // if shouldCloseOnPress is false, we should not close the tooltip + if (!shouldCloseOnPress) { return; } // no matter how the trigger is pressed, we should close the tooltip diff --git a/packages/@react-spectrum/s2/src/Image.tsx b/packages/@react-spectrum/s2/src/Image.tsx index 0cf45380aaf..9fc66e4a032 100644 --- a/packages/@react-spectrum/s2/src/Image.tsx +++ b/packages/@react-spectrum/s2/src/Image.tsx @@ -1,3 +1,4 @@ +import {ColorSchemeContext} from './Provider'; import {ContextValue, SlotProps} from 'react-aria-components'; import {createContext, ForwardedRef, forwardRef, HTMLAttributeReferrerPolicy, JSX, ReactNode, useCallback, useContext, useMemo, useReducer, useRef, version} from 'react'; import {DefaultImageGroup, ImageGroup} from './ImageCoordinator'; @@ -9,9 +10,46 @@ import {UnsafeStyles} from './style-utils'; import {useLayoutEffect} from '@react-aria/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; +export interface ImageSource { + /** + * A comma-separated list of image URLs and descriptors. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#srcset). + */ + srcSet?: string | undefined, + /** + * The color scheme for this image source. Unlike `media`, this respects the `Provider` color scheme setting. + */ + colorScheme?: 'light' | 'dark', + /** + * A media query describing when the source should render. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#media). + */ + media?: string | undefined, + /** + * A list of source sizes that describe the final rendered width of the image. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#sizes). + */ + sizes?: string | undefined, + /** + * The mime type of the image. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#type). + */ + type?: string | undefined, + /** + * The intrinsic width of the image. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#width). + */ + width?: number, + /** + * The intrinsic height of the image. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#height). + */ + height?: number +} + export interface ImageProps extends UnsafeStyles, SlotProps { - /** The URL of the image. */ - src?: string, + /** The URL of the image or a list of conditional sources. */ + src?: string | ImageSource[], // TODO // srcSet?: string, // sizes?: string, @@ -61,10 +99,6 @@ export interface ImageProps extends UnsafeStyles, SlotProps { * If not provided, the default image group is used. */ group?: ImageGroup, - /** - * Child `` elements defining alternate versions of an image for different display/device scenarios. - */ - children?: ReactNode, /** * Associates the image with a microdata object. * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/itemprop). @@ -159,7 +193,7 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar [props, domRef] = useSpectrumContextProps(props, domRef, ImageContext); let { - src = '', + src: srcProp = '', styles, UNSAFE_className = '', UNSAFE_style, @@ -177,16 +211,17 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar slot, width, height, - children, itemProp } = props; let hidden = (props as ImageContextValue).hidden; + let colorScheme = useContext(ColorSchemeContext); + let cacheKey = useMemo(() => typeof srcProp === 'object' ? JSON.stringify(srcProp) : srcProp, [srcProp]); let {revealAll, register, unregister, load} = useContext(group); - let [{state, src: lastSrc, loadTime}, dispatch] = useReducer(reducer, src, createState); + let [{state, src: lastCacheKey, loadTime}, dispatch] = useReducer(reducer, cacheKey, createState); - if (src !== lastSrc && !hidden) { - dispatch({type: 'update', src}); + if (cacheKey !== lastCacheKey && !hidden) { + dispatch({type: 'update', src: cacheKey}); } if (state === 'loaded' && revealAll && !hidden) { @@ -199,21 +234,21 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar return; } - register(src); + register(cacheKey); return () => { - unregister(src); + unregister(cacheKey); }; - }, [hidden, register, unregister, src]); + }, [hidden, register, unregister, cacheKey]); let onLoad = useCallback(() => { - load(src); + load(cacheKey); dispatch({type: 'loaded'}); - }, [load, src]); + }, [load, cacheKey]); let onError = useCallback(() => { dispatch({type: 'error'}); - unregister(src); - }, [unregister, src]); + unregister(cacheKey); + }, [unregister, cacheKey]); let isSkeleton = useIsSkeleton(); let isAnimating = isSkeleton || state === 'loading' || state === 'loaded'; @@ -223,15 +258,20 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar return; } + // In React act environments, run immediately. + // @ts-ignore + let isTestEnv = typeof IS_REACT_ACT_ENVIRONMENT === 'boolean' ? IS_REACT_ACT_ENVIRONMENT : typeof jest !== 'undefined'; + let runTask = isTestEnv ? fn => fn() : queueMicrotask; + // If the image is already loaded, update state immediately instead of waiting for onLoad. let img = imgRef.current; if (state === 'loading' && img?.complete) { if (img.naturalWidth === 0 && img.naturalHeight === 0) { // Queue a microtask so we don't hit React's update limit. // TODO: is this necessary? - queueMicrotask(onError); + runTask(onError); } else { - queueMicrotask(onLoad); + runTask(onLoad); } } @@ -253,7 +293,7 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar let img = ( {alt} ); - if (children) { + if (Array.isArray(srcProp)) { img = ( - {children} + {srcProp.map((source, i) => { + let {colorScheme: sourceColorScheme, ...sourceProps} = source; + if (sourceColorScheme) { + if (!colorScheme || colorScheme === 'light dark') { + return ( + + ); + } + + return sourceColorScheme === colorScheme + ? + : null; + } else { + return ; + } + })} {img} ); @@ -287,7 +345,7 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar {!errorState && img} ); - }, [slot, hidden, domRef, UNSAFE_style, UNSAFE_className, styles, isAnimating, errorState, src, alt, crossOrigin, decoding, fetchPriority, loading, referrerPolicy, width, height, onLoad, onError, isRevealed, isTransitioning, children, itemProp]); + }, [slot, hidden, domRef, UNSAFE_style, UNSAFE_className, styles, isAnimating, errorState, alt, crossOrigin, decoding, fetchPriority, loading, referrerPolicy, width, height, onLoad, onError, isRevealed, isTransitioning, srcProp, itemProp, colorScheme]); }); function getFetchPriorityProp(fetchPriority?: 'high' | 'low' | 'auto'): Record { diff --git a/packages/@react-spectrum/s2/src/Skeleton.tsx b/packages/@react-spectrum/s2/src/Skeleton.tsx index b8ebd13cba4..1cc218bb3d6 100644 --- a/packages/@react-spectrum/s2/src/Skeleton.tsx +++ b/packages/@react-spectrum/s2/src/Skeleton.tsx @@ -22,7 +22,7 @@ export function useLoadingAnimation(isAnimating: boolean): (element: HTMLElement let animationRef = useRef(null); let reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); return useCallback((element: HTMLElement | null) => { - if (isAnimating && !animationRef.current && element && !reduceMotion) { + if (isAnimating && !animationRef.current && element && !reduceMotion && typeof element.animate === 'function') { // Use web animation API instead of CSS animations so that we can // synchronize it between all loading elements on the page (via startTime). animationRef.current = element.animate( diff --git a/packages/@react-spectrum/s2/test/Image.test.tsx b/packages/@react-spectrum/s2/test/Image.test.tsx new file mode 100644 index 00000000000..014a0f01cbf --- /dev/null +++ b/packages/@react-spectrum/s2/test/Image.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2025 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 {Image, Provider} from '../src'; +import {render} from '@react-spectrum/test-utils-internal'; + +describe('Image', () => { + it('should support conditional sources', async () => { + let {getByRole} = render( + test= 500px)'}, + {srcSet: 'default.png'} + ]} /> + ); + + let img = getByRole('img'); + let picture = img.parentElement!; + expect(picture.tagName).toBe('PICTURE'); + let sources = picture.querySelectorAll('source'); + + expect(sources).toHaveLength(3); + expect(sources[0]).toHaveAttribute('srcset', 'foo.png'); + expect(sources[0]).toHaveAttribute('type', 'image/png'); + expect(sources[0]).toHaveAttribute('media', '(prefers-color-scheme: light)'); + expect(sources[1]).toHaveAttribute('srcset', 'bar.png'); + expect(sources[1]).toHaveAttribute('media', '(width >= 500px) and (prefers-color-scheme: dark)'); + expect(sources[2]).toHaveAttribute('srcset', 'default.png'); + }); + + it('should support conditional sources with Provider colorScheme override', async () => { + let {getByRole} = render( + + test= 500px)'}, + {srcSet: 'default.png'} + ]} /> + + ); + + let img = getByRole('img'); + let picture = img.parentElement!; + expect(picture.tagName).toBe('PICTURE'); + let sources = picture.querySelectorAll('source'); + + expect(sources).toHaveLength(2); + expect(sources[0]).toHaveAttribute('srcset', 'bar.png'); + expect(sources[0]).toHaveAttribute('media', '(width >= 500px)'); + expect(sources[1]).toHaveAttribute('srcset', 'default.png'); + }); +}); diff --git a/packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx b/packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx index 0cb42d22ab7..9530a2404b0 100644 --- a/packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx +++ b/packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx @@ -22,7 +22,7 @@ import {useTooltipTriggerState} from '@react-stately/tooltip'; const DEFAULT_OFFSET = -1; // Offset needed to reach 4px/5px (med/large) distance between tooltip and trigger button const DEFAULT_CROSS_OFFSET = 0; -const DEFAULT_CLOSE_ON_PRESS = true; // Whether the tooltip should close when the trigger is pressed +const DEFAULT_SHOULD_CLOSE_ON_PRESS = true; // Whether the tooltip should close when the trigger is pressed function TooltipTrigger(props: SpectrumTooltipTriggerProps) { let { @@ -31,7 +31,7 @@ function TooltipTrigger(props: SpectrumTooltipTriggerProps) { isDisabled, offset = DEFAULT_OFFSET, trigger: triggerAction, - closeOnPress = DEFAULT_CLOSE_ON_PRESS + shouldCloseOnPress = DEFAULT_SHOULD_CLOSE_ON_PRESS } = props; let [trigger, tooltip] = React.Children.toArray(children) as [ReactElement, ReactElement]; @@ -43,7 +43,7 @@ function TooltipTrigger(props: SpectrumTooltipTriggerProps) { let {triggerProps, tooltipProps} = useTooltipTrigger({ isDisabled, trigger: triggerAction, - closeOnPress + shouldCloseOnPress }, state, tooltipTriggerRef); let [borderRadius, setBorderRadius] = useState(0); diff --git a/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx b/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx index 48becc8c2bf..bcbdebd4b56 100644 --- a/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx +++ b/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx @@ -73,7 +73,7 @@ const argTypes = { children: { control: {disable: true} }, - closeOnPress: { + shouldCloseOnPress: { control: 'boolean' } }; @@ -117,7 +117,7 @@ export default { Change Name ], onOpenChange: action('openChange'), - closeOnPress: true + shouldCloseOnPress: true }, argTypes: argTypes } as Meta; diff --git a/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js b/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js index 5a898b7450e..9a6576f521a 100644 --- a/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js +++ b/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js @@ -330,10 +330,10 @@ describe('TooltipTrigger', function () { expect(queryByRole('tooltip')).toBeNull(); }); - it('does not close if the trigger is clicked when closeOnPress is false', async () => { + it('does not close if the trigger is clicked when shouldCloseOnPress is false', async () => { let {getByRole, getByLabelText} = render( - + Helpful information. @@ -351,10 +351,10 @@ describe('TooltipTrigger', function () { expect(tooltip).toBeVisible(); }); - it('does not close if the trigger is clicked with the keyboard when closeOnPress is false', async () => { + it('does not close if the trigger is clicked with the keyboard when shouldCloseOnPress is false', async () => { let {getByRole, getByLabelText} = render( - + Helpful information. diff --git a/packages/@react-stately/utils/src/useControlledState.ts b/packages/@react-stately/utils/src/useControlledState.ts index 0e52b499b6b..76d735b09a9 100644 --- a/packages/@react-stately/utils/src/useControlledState.ts +++ b/packages/@react-stately/utils/src/useControlledState.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import React, {SetStateAction, useCallback, useEffect, useRef, useState} from 'react'; +import React, {SetStateAction, useCallback, useEffect, useReducer, useRef, useState} from 'react'; // Use the earliest effect possible to reset the ref below. const useEarlyEffect: typeof React.useLayoutEffect = typeof document !== 'undefined' @@ -43,6 +43,7 @@ export function useControlledState(value: T, defaultValue: T, onChange valueRef.current = currentValue; }); + let [, forceUpdate] = useReducer(() => ({}), {}); let setValue = useCallback((value: SetStateAction, ...args: any[]) => { // @ts-ignore - TS doesn't know that T cannot be a function. let newValue = typeof value === 'function' ? value(valueRef.current) : value; @@ -50,9 +51,11 @@ export function useControlledState(value: T, defaultValue: T, onChange // Update the ref so that the next setState callback has the most recent value. valueRef.current = newValue; - // Always trigger a setState, even when controlled, so that the layout effect above runs to reset the value. setStateValue(newValue); + // Always trigger a re-render, even when controlled, so that the layout effect above runs to reset the value. + forceUpdate(); + // Trigger onChange. Note that if setState is called multiple times in a single event, // onChange will be called for each one instead of only once. onChange?.(newValue, ...args); diff --git a/packages/@react-stately/utils/test/useControlledState.test.tsx b/packages/@react-stately/utils/test/useControlledState.test.tsx index 18c9307afe5..a2f3760d313 100644 --- a/packages/@react-stately/utils/test/useControlledState.test.tsx +++ b/packages/@react-stately/utils/test/useControlledState.test.tsx @@ -212,6 +212,16 @@ describe('useControlledState tests', function () { onChangeSpy.mockClear(); + act(() => setValue((prevValue) => { + expect(prevValue).toBe('updated'); + return 'newValue'; + })); + [value, setValue] = result.current; + expect(value).toBe('updated'); + expect(onChangeSpy).toHaveBeenLastCalledWith('newValue'); + + onChangeSpy.mockClear(); + act(() => setValue((prevValue) => { expect(prevValue).toBe('updated'); return 'updated'; diff --git a/packages/@react-types/tooltip/src/index.d.ts b/packages/@react-types/tooltip/src/index.d.ts index 73730df859f..5bac687bbb5 100644 --- a/packages/@react-types/tooltip/src/index.d.ts +++ b/packages/@react-types/tooltip/src/index.d.ts @@ -42,7 +42,7 @@ export interface TooltipTriggerProps extends OverlayTriggerProps { * Whether the tooltip should close when the trigger is pressed. * @default true */ - closeOnPress?: boolean + shouldCloseOnPress?: boolean } export interface SpectrumTooltipTriggerProps extends Omit, PositionProps { diff --git a/packages/dev/docs/pages/assets/component-illustrations/InternationalizedDefault.svg b/packages/dev/docs/pages/assets/component-illustrations/InternationalizedDefault.svg index 7be422befdf..3968013e2c1 100644 --- a/packages/dev/docs/pages/assets/component-illustrations/InternationalizedDefault.svg +++ b/packages/dev/docs/pages/assets/component-illustrations/InternationalizedDefault.svg @@ -1,10 +1,3 @@ - - - - - - - - - + + diff --git a/packages/dev/s2-docs/assets/internationalized.ico b/packages/dev/s2-docs/assets/internationalized.ico index d0dee54937d..8aec276c938 100644 Binary files a/packages/dev/s2-docs/assets/internationalized.ico and b/packages/dev/s2-docs/assets/internationalized.ico differ diff --git a/packages/dev/s2-docs/pages/react-aria/getting-started.mdx b/packages/dev/s2-docs/pages/react-aria/getting-started.mdx index df2fe12d6ab..a86caad5719 100644 --- a/packages/dev/s2-docs/pages/react-aria/getting-started.mdx +++ b/packages/dev/s2-docs/pages/react-aria/getting-started.mdx @@ -28,7 +28,7 @@ Install React Aria with your preferred package manager. ## Quick start -The documentation for each component includes vanilla CSS and [Tailwind](https://tailwindcss.com) examples. Copy and paste these into your project and make them your own. You can also download each example as a ZIP or open in CodeSandbox or StackBlitz. +The documentation for each component includes vanilla CSS and [Tailwind](https://tailwindcss.com) examples. Copy and paste these into your project and make them your own. You can also download each example as a ZIP or open in StackBlitz. ```tsx render docs={docs.exports.Select} links={docs.links} props={[]} type="vanilla" files={["starters/docs/src/Select.tsx", "starters/docs/src/Select.css"]} showCoachMark diff --git a/packages/dev/s2-docs/pages/s2/Image.mdx b/packages/dev/s2-docs/pages/s2/Image.mdx index 59b4629fe90..d1487fdd169 100644 --- a/packages/dev/s2-docs/pages/s2/Image.mdx +++ b/packages/dev/s2-docs/pages/s2/Image.mdx @@ -21,6 +21,29 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; styles={style({width: 400, maxWidth: 'full', borderRadius: 'default'})} /> ``` +## Conditional sources + +Set the `src` prop to an array of objects describing conditional images, e.g. media queries or image formats. These accept the same props as the <[source](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source)> HTML element, as well as `colorScheme` to conditionally render images based on the [Provider](Provider) color scheme. + +```tsx render type="s2" wide docs={docs.exports.Provider} links={docs.links} props={['colorScheme']} +"use client"; +import {Image, Provider} from '@react-spectrum/s2'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + + + + +``` + + ## Error state Use `renderError` to display a custom error UI when an image fails to load. diff --git a/packages/dev/s2-docs/pages/s2/Tooltip.mdx b/packages/dev/s2-docs/pages/s2/Tooltip.mdx index 70b153d3a40..0d0e836aff0 100644 --- a/packages/dev/s2-docs/pages/s2/Tooltip.mdx +++ b/packages/dev/s2-docs/pages/s2/Tooltip.mdx @@ -11,7 +11,7 @@ export const description = 'Displays a description of an element on hover or foc {docs.exports.Tooltip.description} -```tsx render docs={docs.exports.TooltipTrigger} links={docs.links} props={['placement', 'crossOffset', 'shouldFlip']} type="s2" +```tsx render docs={docs.exports.TooltipTrigger} links={docs.links} props={['placement', 'crossOffset', 'shouldFlip', 'shouldCloseOnPress']} type="s2" "use client"; import {Tooltip, TooltipTrigger, ActionButton} from '@react-spectrum/s2'; import Edit from '@react-spectrum/s2/icons/Edit'; diff --git a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs index d04e6c34772..6a52f9908d7 100644 --- a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs +++ b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs @@ -3,7 +3,6 @@ import * as babel from '@babel/parser'; import {fileURLToPath} from 'url'; import fs from 'fs'; -import {getBaseUrl} from '../src/pageUtils.ts'; import glob from 'fast-glob'; import path from 'path'; import {Project} from 'ts-morph'; @@ -13,6 +12,33 @@ import remarkStringify from 'remark-stringify'; import {unified} from 'unified'; import {visit} from 'unist-util-visit'; +const BASE_URL = { + dev: { + 'react-aria': 'http://localhost:1234', + 's2': 'http://localhost:4321' + }, + stage: { + 'react-aria': 'https://d5iwopk28bdhl.cloudfront.net', + 's2': 'https://d1pzu54gtk2aed.cloudfront.net' + }, + prod: { + 'react-aria': 'https://react-aria.adobe.com', + 's2': 'https://react-spectrum.adobe.com' + } +}; + +function getBaseUrl(library) { + let env = process.env.DOCS_ENV; + let base = env + ? BASE_URL[env][library] + : `http://localhost:1234/${library}`; + let publicUrl = process.env.PUBLIC_URL; + if (publicUrl) { + base += publicUrl.replace(/\/$/, ''); + } + return base; +} + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '../../../../'); const S2_SRC_ROOT = path.join(REPO_ROOT, 'packages/@react-spectrum/s2/src'); @@ -938,61 +964,243 @@ function remarkDocsComponentsToMarkdown() { exampleTitles = Array.isArray(parsed) ? parsed : []; } - // Fallback default titles when none were provided. - if (exampleTitles.length === 0) { - exampleTitles = ['Vanilla CSS', 'Tailwind']; - } - - // Children may include whitespace/text nodes – filter to VisualExample elements. const visualChildren = (node.children || []).filter(c => c.type === 'mdxJsxFlowElement' && c.name === 'VisualExample'); + const codeChildren = (node.children || []).filter(c => c.type === 'code'); // Build replacement markdown nodes. const newNodes = []; - visualChildren.forEach((vChild, i) => { - const title = exampleTitles[i] || `Example ${i + 1}`; - - // ## {title} example - newNodes.push({ - type: 'heading', - depth: 2, - children: [{type: 'text', value: `${title} example`}] - }); - - // Extract files attribute from VisualExample - const filesAttr = vChild.attributes?.find(a => a.name === 'files'); - let fileList = []; - if (filesAttr) { - if (filesAttr.value?.type === 'mdxJsxAttributeValueExpression') { - const parsed = parseExpression(filesAttr.value.value, file); - fileList = Array.isArray(parsed) ? parsed : []; - } else if (Array.isArray(filesAttr.value)) { - fileList = filesAttr.value; - } + if (visualChildren.length > 0) { + if (exampleTitles.length === 0) { + exampleTitles = ['Vanilla CSS', 'Tailwind']; } - fileList.forEach(fp => { - const absPath = path.join(REPO_ROOT, fp); - if (!fs.existsSync(absPath)) {return;} - const contents = fs.readFileSync(absPath, 'utf8'); - const ext = path.extname(fp).slice(1); + visualChildren.forEach((vChild, i) => { + const title = exampleTitles[i] || `Example ${i + 1}`; - // ### {filename} + // ## {title} example newNodes.push({ type: 'heading', - depth: 3, - children: [{type: 'text', value: path.basename(fp)}] + depth: 2, + children: [{type: 'text', value: `${title} example`}] }); - // ```{lang}\n{contents}\n``` - newNodes.push({ - type: 'code', - lang: ext || undefined, - meta: '', - value: contents + // Extract files attribute from VisualExample + const filesAttr = vChild.attributes?.find(a => a.name === 'files'); + let fileList = []; + if (filesAttr) { + if (filesAttr.value?.type === 'mdxJsxAttributeValueExpression') { + const parsed = parseExpression(filesAttr.value.value, file); + fileList = Array.isArray(parsed) ? parsed : []; + } else if (Array.isArray(filesAttr.value)) { + fileList = filesAttr.value; + } + } + + fileList.forEach(fp => { + const absPath = path.join(REPO_ROOT, fp); + if (!fs.existsSync(absPath)) {return;} + const contents = fs.readFileSync(absPath, 'utf8'); + const ext = path.extname(fp).slice(1); + + // ### {filename} + newNodes.push({ + type: 'heading', + depth: 3, + children: [{type: 'text', value: path.basename(fp)}] + }); + + // ```{lang}\n{contents}\n``` + newNodes.push({ + type: 'code', + lang: ext || undefined, + meta: '', + value: contents + }); }); }); - }); + } + + // Handle code block children (type="vanilla"|"tailwind" and files=[...]) + if (codeChildren.length > 0) { + // Parse metadata from code blocks to extract type and files + const parseCodeMeta = (meta) => { + if (!meta) {return {};} + const result = {}; + + // Extract type + const typeMatch = meta.match(/type=["']([^"']+)["']/); + if (typeMatch) { + result.type = typeMatch[1]; + } + + // Extract files={[...]} + const filesMatch = meta.match(/files=\{(\[[^\]]+\])\}/); + if (filesMatch) { + try { + result.files = JSON.parse(filesMatch[1]); + } catch { + const parsed = parseExpression(filesMatch[1], file); + if (Array.isArray(parsed)) { + result.files = parsed; + } + } + } + + return result; + }; + + const typeToTitle = { + 'vanilla': 'Vanilla CSS', + 'tailwind': 'Tailwind' + }; + + // Check if this is a "component" type ExampleSwitcher (each code block gets its own example title) + const typeAttr = node.attributes?.find(a => a.name === 'type'); + let switcherType = null; + if (typeAttr) { + if (typeAttr.value?.type === 'mdxJsxAttributeValueExpression') { + switcherType = typeAttr.value.value.replace(/['"`]/g, '').trim(); + } else if (typeof typeAttr.value === 'string') { + switcherType = typeAttr.value.trim(); + } + } + + if (switcherType === 'component' && exampleTitles.length > 0) { + // Each code block gets its own heading from the examples array + codeChildren.forEach((codeChild, i) => { + const title = exampleTitles[i] || `Example ${i + 1}`; + const meta = parseCodeMeta(codeChild.meta); + + // ## {title} example + newNodes.push({ + type: 'heading', + depth: 2, + children: [{type: 'text', value: `${title} example`}] + }); + + // Clean up the code value + let codeValue = codeChild.value; + if (codeValue.startsWith('"use client";\n')) { + codeValue = codeValue.slice(14); + } + // Remove docs rendering-specific comments + codeValue = codeValue + .split('\n') + .filter(l => !/^\s*\/\/\/-\s*(begin|end)/i.test(l)) + .map(l => l.replace(/\/\*\s*PROPS\s*\*\//gi, '')) + .join('\n'); + + newNodes.push({ + type: 'code', + lang: codeChild.lang || 'tsx', + meta: '', + value: codeValue + }); + + // Add referenced files for this specific example + if (meta.files && Array.isArray(meta.files)) { + meta.files.forEach(fp => { + const absPath = path.join(REPO_ROOT, fp); + if (!fs.existsSync(absPath)) {return;} + const contents = fs.readFileSync(absPath, 'utf8'); + const ext = path.extname(fp).slice(1); + + // ### {filename} + newNodes.push({ + type: 'heading', + depth: 3, + children: [{type: 'text', value: path.basename(fp)}] + }); + + // ```{lang}\n{contents}\n``` + newNodes.push({ + type: 'code', + lang: ext || undefined, + meta: '', + value: contents + }); + }); + } + }); + } else { + // Group code blocks by type (vanilla, tailwind, etc.) + const codeBlocksByType = new Map(); + codeChildren.forEach((codeChild) => { + const meta = parseCodeMeta(codeChild.meta); + const type = meta.type || 'vanilla'; + if (!codeBlocksByType.has(type)) { + codeBlocksByType.set(type, []); + } + codeBlocksByType.get(type).push({code: codeChild, meta}); + }); + + // Process each type group + for (const [type, codeBlocks] of codeBlocksByType) { + const title = typeToTitle[type] || type.charAt(0).toUpperCase() + type.slice(1); + + // ## {title} example + newNodes.push({ + type: 'heading', + depth: 2, + children: [{type: 'text', value: `${title} example`}] + }); + + // Collect all unique files from all code blocks of this type + const allFiles = new Set(); + codeBlocks.forEach(({meta}) => { + if (meta.files && Array.isArray(meta.files)) { + meta.files.forEach(f => allFiles.add(f)); + } + }); + + // Add the inline example code first + codeBlocks.forEach(({code}) => { + // Clean up the code value + let codeValue = code.value; + if (codeValue.startsWith('"use client";\n')) { + codeValue = codeValue.slice(14); + } + // Remove docs rendering-specific comments + codeValue = codeValue + .split('\n') + .filter(l => !/^\s*\/\/\/-\s*(begin|end)/i.test(l)) + .map(l => l.replace(/\/\*\s*PROPS\s*\*\//gi, '')) + .join('\n'); + + newNodes.push({ + type: 'code', + lang: code.lang || 'tsx', + meta: '', + value: codeValue + }); + }); + + // Add referenced files + allFiles.forEach(fp => { + const absPath = path.join(REPO_ROOT, fp); + if (!fs.existsSync(absPath)) {return;} + const contents = fs.readFileSync(absPath, 'utf8'); + const ext = path.extname(fp).slice(1); + + // ### {filename} + newNodes.push({ + type: 'heading', + depth: 3, + children: [{type: 'text', value: path.basename(fp)}] + }); + + // ```{lang}\n{contents}\n``` + newNodes.push({ + type: 'code', + lang: ext || undefined, + meta: '', + value: contents + }); + }); + } + } + } // Replace ExampleSwitcher node with generated markdown. parent.children.splice(index, 1, ...newNodes); diff --git a/packages/dev/s2-docs/src/CodePlatter.tsx b/packages/dev/s2-docs/src/CodePlatter.tsx index 715a4432c80..654dd7a6398 100644 --- a/packages/dev/s2-docs/src/CodePlatter.tsx +++ b/packages/dev/s2-docs/src/CodePlatter.tsx @@ -2,15 +2,15 @@ import {ActionButton, ActionButtonGroup, Button, ButtonGroup, Content, createIcon, Dialog, DialogContainer, Heading, Link, Menu, MenuItem, MenuTrigger, Text, ToastQueue, Tooltip, TooltipTrigger} from '@react-spectrum/s2'; import {CopyButton} from './CopyButton'; -import {createCodeSandbox, getCodeSandboxFiles} from './CodeSandbox'; import {createStackBlitz} from './StackBlitz'; import Download from '@react-spectrum/s2/icons/Download'; import type {DownloadFiles} from './CodeBlock'; +import {getCodeSandboxFiles} from './CodeSandbox'; import {keyframes} from '../../../@react-spectrum/s2/style/style-macro' with {type: 'macro'}; import {Library} from './library'; 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 Polygon4 from '@react-spectrum/s2/icons/Polygon4'; import Prompt from '@react-spectrum/s2/icons/Prompt'; import React, {createContext, ProviderProps, ReactNode, RefObject, useContext, useRef, useState} from 'react'; import {ShadcnCommand} from './ShadcnCommand'; @@ -72,7 +72,7 @@ export function ShareUrlProvider(props: ProviderProps) { export function CodePlatter({children, type, showCoachMark}: CodePlatterProps) { let codeRef = useRef(null); let [showShadcn, setShowShadcn] = useState(false); - let [showCodeSandbox, setShowCodeSandbox] = useState(false); + // let [showCodeSandbox, setShowCodeSandbox] = useState(false); let getText = () => codeRef.current!.querySelector('pre')!.textContent!; let {library} = useContext(CodePlatterContext); if (!type) { @@ -158,7 +158,7 @@ export function CodePlatter({children, type, showCoachMark}: CodePlatterProps) { Install with shadcn } - {files && + {/* {files && { setShowCodeSandbox(true); @@ -166,8 +166,8 @@ export function CodePlatter({children, type, showCoachMark}: CodePlatterProps) { Open in CodeSandbox - } - {files && type !== 's2' && + } */} + {files && { createStackBlitz(getExampleFiles(codeRef, files, urls, entry), deps, type, entry); @@ -192,11 +192,11 @@ export function CodePlatter({children, type, showCoachMark}: CodePlatterProps) {
{children}
- setShowCodeSandbox(false)}> + {/* setShowCodeSandbox(false)}> {showCodeSandbox && } - + */} setShowShadcn(false)}> {showShadcn && @@ -250,9 +250,12 @@ function getExampleCode(codeRef: RefObject, urls: {[name: } } - return code + if (!code.includes('export default function')) { // Export the last function - .replace(/\nfunction ([^(]+)((.|\n)+\n\}\n?)$/, '\nexport default function Example$2') + code = code.replace(/\nfunction ([^(]+)((.|\n)+\n\}\n?)$/, '\nexport default function Example$2'); + } + + return code // Add function wrapper around raw JSX in examples. .replace(/\n<((?:.|\n)+)/, (_, code) => { let res = '\nexport default function Example() {\n return (\n <'; @@ -319,31 +322,31 @@ function ShadcnDialog({registryUrl}) { ); } -function CodeSandboxDialog({getExampleFiles, codeRef, files, urls, entry, deps, type}) { - return ( - - {({close}) => (<> - Create a CodeSandbox - -

This will create an editable sandbox with this example in a new tab.

-

Troubleshooting: If the sandbox fails to open or isn't created, try logging in to CodeSandbox first. If you're already logged in, try signing out and back in.

-
- - - - - - )} -
- ); -} +// function CodeSandboxDialog({getExampleFiles, codeRef, files, urls, entry, deps, type}) { +// return ( +// +// {({close}) => (<> +// Create a CodeSandbox +// +//

This will create an editable sandbox with this example in a new tab.

+//

Troubleshooting: If the sandbox fails to open or isn't created, try logging in to CodeSandbox first. If you're already logged in, try signing out and back in.

+//
+ +// +// +// +// +// )} +//
+// ); +// } const pulseAnimation = keyframes(` 0% { diff --git a/packages/dev/s2-docs/src/ComponentCard.tsx b/packages/dev/s2-docs/src/ComponentCard.tsx index d67b99a36c5..bc5f38db9fa 100644 --- a/packages/dev/s2-docs/src/ComponentCard.tsx +++ b/packages/dev/s2-docs/src/ComponentCard.tsx @@ -408,15 +408,15 @@ function ComponentIllustration({name, href}: IllustrationProps) { return ( - - - + styles={illustrationStyles} /> ); } @@ -443,16 +443,13 @@ export function ComponentCard({id, name, href, description, size, ...otherProps} preview = (
{/* Background gradient */} - - - - - + {releaseVersion}
); diff --git a/packages/dev/s2-docs/src/ComponentCardClient.tsx b/packages/dev/s2-docs/src/ComponentCardClient.tsx index 05665f2f76a..ba08de3f9fc 100644 --- a/packages/dev/s2-docs/src/ComponentCardClient.tsx +++ b/packages/dev/s2-docs/src/ComponentCardClient.tsx @@ -2,6 +2,7 @@ import {Card, CardPreview, CardProps, Content, Text} from '@react-spectrum/s2'; import {ReactNode, useEffect, useRef} from 'react'; import {registerSpectrumLink} from './prefetch'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; interface ComponentCardProps extends Omit { preview: ReactNode, @@ -30,7 +31,7 @@ export function ComponentCardClient(props: ComponentCardProps) { {preview} - + {name} {description && {description}} diff --git a/packages/dev/s2-docs/src/ExampleList.tsx b/packages/dev/s2-docs/src/ExampleList.tsx index 3498db4d9b1..821fe699d2a 100644 --- a/packages/dev/s2-docs/src/ExampleList.tsx +++ b/packages/dev/s2-docs/src/ExampleList.tsx @@ -99,12 +99,12 @@ export function ExampleImage({name, itemProp}: {name: string, itemProp?: string} let [light, dark] = img; return ( - - - + styles={image} /> ); } diff --git a/packages/dev/s2-docs/src/StackBlitz.tsx b/packages/dev/s2-docs/src/StackBlitz.tsx index 7d4edaac323..eef2ecc5fdc 100644 --- a/packages/dev/s2-docs/src/StackBlitz.tsx +++ b/packages/dev/s2-docs/src/StackBlitz.tsx @@ -65,6 +65,7 @@ function getFiles( dependencies: { react: '^19', 'react-dom': '^19', + ...(type === 's2' ? {'@react-spectrum/s2': 'latest'} : {}), ...deps }, devDependencies: { @@ -77,17 +78,21 @@ function getFiles( '@tailwindcss/vite': '^4', 'tailwindcss-react-aria-components': '^2', 'tailwindcss-animate': '^1' + } : {}), + ...(type === 's2' ? { + 'unplugin-parcel-macros': '^0.1.2-alpha.1' } : {}) } }, null, 2) + '\n', 'vite.config.ts': `import {defineConfig} from 'vite'; -import react from '@vitejs/plugin-react';${type === 'tailwind' ? "\nimport tailwindcss from '@tailwindcss/vite';" : ''} +import react from '@vitejs/plugin-react';${type === 'tailwind' ? "\nimport tailwindcss from '@tailwindcss/vite';" : ''}${type === 's2' ? "\nimport macros from 'unplugin-parcel-macros';" : ''} export default defineConfig({ - plugins: [react()${type === 'tailwind' ? ', tailwindcss()' : ''}], + plugins: [${type === 's2' ? 'macros.vite(), ' : ''}react()${type === 'tailwind' ? ', tailwindcss()' : ''}], }); `, - 'index.html': ` + 'index.html': ` + Test @@ -99,10 +104,10 @@ export default defineConfig({ `, - 'src/index.tsx': `import {createRoot} from 'react-dom/client'; + 'src/index.tsx': `import {createRoot} from 'react-dom/client';${type === 's2' ? "\nimport '@react-spectrum/s2/page.css';\nimport {Provider} from '@react-spectrum/s2';" : ''} import ${entryName} from './${entryName}'; -createRoot(document.getElementById('root')!).render(<${entryName} />); +createRoot(document.getElementById('root')!).render(${type === 's2' ? `\n \n <${entryName} />\n \n` : `<${entryName} />`}); `, 'tsconfig.json': JSON.stringify({ compilerOptions: { diff --git a/packages/dev/s2-docs/src/VisualExampleClient.tsx b/packages/dev/s2-docs/src/VisualExampleClient.tsx index 8338e39a527..0d12be6d428 100644 --- a/packages/dev/s2-docs/src/VisualExampleClient.tsx +++ b/packages/dev/s2-docs/src/VisualExampleClient.tsx @@ -444,6 +444,9 @@ export function Control({name}: {name: string}) { if (name === 'placement' && control.value.elements.length === 22) { return ; } + if (name === 'src') { + return ; + } return ; case 'number': return ; diff --git a/packages/dev/s2-docs/src/client.tsx b/packages/dev/s2-docs/src/client.tsx index dfd951d002e..1f821c3b3a9 100644 --- a/packages/dev/s2-docs/src/client.tsx +++ b/packages/dev/s2-docs/src/client.tsx @@ -7,6 +7,11 @@ import {type ReactElement} from 'react'; import {setNavigationPromise} from './Router'; import {ToastQueue} from '@react-spectrum/s2'; +if ('scrollRestoration' in history) { + // Disable browser's automatic scroll restoration since we handle it manually + history.scrollRestoration = 'manual'; +} + // Hydrate initial RSC payload embedded in the HTML. let updateRoot = hydrate({ // Intercept HMR window reloads, and do it with RSC instead. @@ -19,16 +24,56 @@ let updateRoot = hydrate({ let currentNavigationId = 0; let currentAbortController: AbortController | null = null; +interface HistoryState { + scrollTop?: number, + windowScrollTop?: number +} + +function getScrollContainer(): HTMLElement | null { + return document.querySelector('main'); +} + +function saveScrollPosition() { + let scrollContainer = getScrollContainer(); + let scrollTop = scrollContainer?.scrollTop ?? 0; + let windowScrollTop = window.scrollY; + let state: HistoryState = { + ...(history.state as HistoryState | null), + scrollTop, + windowScrollTop + }; + history.replaceState(state, '', location.href); +} + +function restoreScrollPosition(state: HistoryState | null) { + if (state?.scrollTop != null || state?.windowScrollTop != null) { + requestAnimationFrame(() => { + let scrollContainer = getScrollContainer(); + if (scrollContainer && state.scrollTop != null) { + scrollContainer.scrollTop = state.scrollTop; + } + if (state.windowScrollTop != null) { + window.scrollTo(0, state.windowScrollTop); + } + }); + } +} + // A very simple router. When we navigate, we'll fetch a new RSC payload from the server, // and in a React transition, stream in the new page. Once complete, we'll pushState to // update the URL in the browser. -async function navigate(pathname: string, push = false) { +async function navigate(pathname: string, push = false, popstateState: HistoryState | null = null) { let url = new URL(pathname, location.href); let basePath = url.pathname; let pathAnchor = url.hash.slice(1); let currentPath = location.pathname; let isSamePageAnchor = (!basePath || basePath === currentPath) && pathAnchor; + // Save scroll position to current history entry before navigating away + if (push) { + saveScrollPosition(); + } + if (isSamePageAnchor) { if (push) { history.pushState(null, '', pathname); @@ -86,10 +131,19 @@ async function navigate(pathname: string, push = false) { push = false; } - // Reset scroll if navigating to a different page without an anchor - if (currentPath !== newBasePath && !newPathAnchor) { + // Handle scroll position + if (popstateState) { + // Restore scroll position from history state (back/forward navigation) + restoreScrollPosition(popstateState); + } else if (currentPath !== newBasePath && !newPathAnchor) { + // Reset scroll for forward navigation to a different page without an anchor + let scrollContainer = getScrollContainer(); + if (scrollContainer) { + scrollContainer.scrollTop = 0; + } window.scrollTo(0, 0); } else if (newPathAnchor) { + // Scroll to anchor let element = document.getElementById(newPathAnchor); if (element) { element.scrollIntoView(); @@ -244,11 +298,22 @@ document.addEventListener('click', e => { } }); -// When the user clicks the back button, navigate with RSC. -window.addEventListener('popstate', () => { - navigate(location.pathname + location.search + location.hash); +// When the user clicks the back/forward button, navigate with RSC. +window.addEventListener('popstate', (e) => { + navigate(location.pathname + location.search + location.hash, false, e.state as HistoryState | null); }); +// Save scroll position to history state when scrolling stops. +let scrollSaveTimeout: ReturnType | null = null; +function onScroll() { + if (scrollSaveTimeout) { + clearTimeout(scrollSaveTimeout); + } + scrollSaveTimeout = setTimeout(saveScrollPosition, 150); +} + +window.addEventListener('scroll', onScroll, {passive: true, capture: true}); + function scrollToCurrentHash() { if (!location.hash || location.hash === '#') { return; @@ -276,7 +341,5 @@ function scrollToCurrentHash() { if (document.readyState === 'complete' || document.readyState === 'interactive') { scrollToCurrentHash(); } else { - window.addEventListener('DOMContentLoaded', () => { - scrollToCurrentHash(); - }, {once: true}); + window.addEventListener('DOMContentLoaded', scrollToCurrentHash, {once: true}); } diff --git a/packages/dev/s2-docs/src/icons/InternationalizedLogo.tsx b/packages/dev/s2-docs/src/icons/InternationalizedLogo.tsx index 493b48da68f..52d122ec587 100644 --- a/packages/dev/s2-docs/src/icons/InternationalizedLogo.tsx +++ b/packages/dev/s2-docs/src/icons/InternationalizedLogo.tsx @@ -1,46 +1,23 @@ -import React, {useId} from 'react'; +import React from 'react'; +import {style} from '@react-spectrum/s2/style' with { type: 'macro' }; export const InternationalizedLogo = ({size = 32}) => { - const clipPathId = `internationalized-logo-clip-${useId()}`; return ( - - - - - - - - - - - - - - - + viewBox="20 20 120 120" + style={{display: 'block', width: size, height: size}} + className={style({ + '--internationalized-logo-color': { + type: 'backgroundColor', + value: 'blue-900' + } + })}> + ); };