diff --git a/packages/@react-spectrum/s2/src/CoachMark.tsx b/packages/@react-spectrum/s2/src/CoachMark.tsx index d84dde75282..8bf076df815 100644 --- a/packages/@react-spectrum/s2/src/CoachMark.tsx +++ b/packages/@react-spectrum/s2/src/CoachMark.tsx @@ -27,7 +27,6 @@ import { import {ButtonContext} from './Button'; import {Card} from './Card'; import {CheckboxContext} from './Checkbox'; -import {colorScheme, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {ColorSchemeContext} from './Provider'; import {ContentContext, FooterContext, KeyboardContext, TextContext} from './Content'; import { @@ -40,6 +39,7 @@ import { } from 'react'; import {DividerContext} from './Divider'; import {forwardRefType} from './types'; +import {getAllowedOverrides, setColorScheme, StyleProps} from './style-utils' with {type: 'macro'}; import {GlobalDOMAttributes} from '@react-types/shared'; import {ImageContext} from './Image'; import {ImageCoordinator} from './ImageCoordinator'; @@ -106,7 +106,7 @@ const slideLeftKeyframes = keyframes(` `); let popover = style({ - ...colorScheme(), + ...setColorScheme(), '--s2-container-bg': { type: 'backgroundColor', value: 'layer-2' diff --git a/packages/@react-spectrum/s2/src/Modal.tsx b/packages/@react-spectrum/s2/src/Modal.tsx index a7927be2941..b41f7e847f9 100644 --- a/packages/@react-spectrum/s2/src/Modal.tsx +++ b/packages/@react-spectrum/s2/src/Modal.tsx @@ -10,11 +10,11 @@ * governing permissions and limitations under the License. */ -import {colorScheme} from './style-utils' with {type: 'macro'}; import {ColorSchemeContext} from './Provider'; import {DOMRef, GlobalDOMAttributes} from '@react-types/shared'; import {forwardRef, MutableRefObject, useCallback, useContext} from 'react'; import {ModalOverlay, ModalOverlayProps, Modal as RACModal, useLocale} from 'react-aria-components'; +import {setColorScheme} from './style-utils' with {type: 'macro'}; import {style} from '../style' with {type: 'macro'}; import {useDOMRef} from '@react-spectrum/utils'; @@ -28,7 +28,7 @@ interface ModalProps extends Omit({ - ...colorScheme(), + ...setColorScheme(), justifyContent: 'center', alignItems: 'center', maxWidth: 160, diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 069647cbb85..4241bb8e699 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -92,6 +92,13 @@ export {TreeView, TreeViewItem, TreeViewItemContent, TreeViewLoadMoreItem} from export {pressScale} from './pressScale'; +export { + centerPadding, + setColorScheme +} from './style-utils'; + +export {mergeStyles} from '../style/runtime'; + export {Autocomplete, Collection, FileTrigger, parseColor, useLocale} from 'react-aria-components'; export {useListData, useTreeData, useAsyncList} from 'react-stately'; @@ -171,3 +178,14 @@ export type {TooltipProps} from './Tooltip'; export type {TreeViewProps, TreeViewItemProps, TreeViewItemContentProps, TreeViewLoadMoreItemProps} from './TreeView'; export type {AutocompleteProps, FileTriggerProps, TooltipTriggerComponentProps as TooltipTriggerProps, SortDescriptor, Color, Key, Selection, RouterConfig} from 'react-aria-components'; export type {ListData, TreeData, AsyncListData} from 'react-stately'; + +export type { + StylesProp, + StylesPropWithHeight, + StylesPropWithoutWidth, + UnsafeClassName, + UnsafeStyles, + StyleProps, + WidthProperties, + HeightProperties +} from './style-utils'; diff --git a/packages/@react-spectrum/s2/src/style-utils.ts b/packages/@react-spectrum/s2/src/style-utils.ts index 3c743afee54..dbe5534e097 100644 --- a/packages/@react-spectrum/s2/src/style-utils.ts +++ b/packages/@react-spectrum/s2/src/style-utils.ts @@ -14,6 +14,25 @@ import {CSSProperties} from 'react'; import {fontRelative} from '../style'; import {StyleString} from '../style/types'; +/** + * Calculates vertical padding to center a single line of text within a container. + * Uses the CSS `self()` function and `1lh` unit to compute the padding based on + * the container's minimum height and border widths. + * This is useful for precise vertical centering without introducing a flex/grid layout to the container. + * + * @param minHeight - A CSS expression for the minimum height to center within. Defaults to `'self(minHeight)'`. + * @returns A CSS `calc()` expression wrapped as an arbitrary style value. + * + * @example + * ```tsx + * import {centerPadding} from '@react-spectrum/s2'; + * import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * paddingY: centerPadding() + * }); + * ``` + */ export function centerPadding(minHeight: string = 'self(minHeight)'): `[${string}]` { return `[calc((${minHeight} - self(borderTopWidth, 0px) - self(borderBottomWidth, 0px) - 1lh) / 2)]`; } @@ -113,7 +132,24 @@ export const fieldInput = () => ({ containIntrinsicWidth: 'calc(var(--defaultWidth) - self(paddingStart, 0px) - self(paddingEnd, 0px) - self(borderStartWidth, 0px) - self(borderEndWidth, 0px))' } as const); -export const colorScheme = () => ({ +/** + * Returns style properties that set the CSS `color-scheme` for a component. + * Defaults to the page's color scheme and supports `'light'`, `'dark'`, and `'light dark'` values + * via the `colorScheme` render prop condition. + * Intended for root containers (e.g. providers, modals, and popovers), and not needed for individual components. + * + * @example + * ```tsx + * import {setColorScheme} from '@react-spectrum/s2'; + * import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * ...setColorScheme(), + * backgroundColor: 'layer-1' + * }); + * ``` + */ +export const setColorScheme = () => ({ colorScheme: { // Default to page color scheme if none is defined. default: '[var(--lightningcss-light, light) var(--lightningcss-dark, dark)]', @@ -312,6 +348,9 @@ export const widthProperties = [ 'maxWidth' ] as const; +/** The set of width-related CSS property names (`width`, `minWidth`, `maxWidth`). */ +export type WidthProperties = (typeof widthProperties)[number]; + export const heightProperties = [ 'size', 'height', @@ -319,6 +358,9 @@ export const heightProperties = [ 'maxHeight' ] as const; +/** The set of height-related CSS property names (`height`, `minHeight`, `maxHeight`) plus `size` (which controls both width and height). */ +export type HeightProperties = (typeof heightProperties)[number]; + export type StylesProp = StyleString<(typeof allowedOverrides)[number] | (typeof widthProperties)[number]>; export type StylesPropWithHeight = StyleString<(typeof allowedOverrides)[number] | (typeof widthProperties)[number] | (typeof heightProperties)[number]>; export type StylesPropWithoutWidth = StyleString<(typeof allowedOverrides)[number]>; diff --git a/packages/@react-spectrum/s2/style/index.ts b/packages/@react-spectrum/s2/style/index.ts index 7a847469bd7..719ab7b1bdc 100644 --- a/packages/@react-spectrum/s2/style/index.ts +++ b/packages/@react-spectrum/s2/style/index.ts @@ -15,18 +15,63 @@ import {Inset, fontRelative as internalFontRelative, space as internalSpace, Spa import type {MacroContext} from '@parcel/macros'; import {StyleString} from './types'; -export {baseColor, color, lightDark, colorMix, size, style} from './spectrum-theme'; +export {baseColor, color, lightDark, colorMix, linearGradient, size, style} from './spectrum-theme'; +export {raw, keyframes} from './style-macro'; export type {StyleString} from './types'; -// Wrap these functions in arbitrary value syntax when called from the outside. +/** + * Converts a pixel value to a Spectrum spacing token in `rem` units. + * + * @param px - The spacing in pixels. + * @returns A `rem` value wrapped as an arbitrary style value. + * + * @example + * ```tsx + * import {space} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * gap: space(12) // 12/16 = 0.75rem + * }); + * ``` + */ export function space(px: number): `[${string}]` { return `[${internalSpace(px)}]`; } -export function fontRelative(base: number, baseFontSize?: number): `[${string}]` { +/** + * Converts a pixel value to a font-relative `em` length. Useful for sizing elements + * relative to the current font size. Defaults to a 14px base. + * + * @param base - The pixel value to convert. + * @param baseFontSize - The base font size in pixels to divide by. Defaults to `14`. + * @returns A CSS `em` value wrapped as an arbitrary style value. + * + * @example + * ```tsx + * import {fontRelative} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * gap: fontRelative(2) // 2/14 = ~0.143em + * }); + * ``` + */ +export function fontRelative(base: number, baseFontSize = 14): `[${string}]` { return `[${internalFontRelative(base, baseFontSize)}]`; } +/** + * Returns consistent Spectrum focus ring outline styles for interactive components. + * + * @example + * ```tsx + * import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * ...focusRing(), + * borderRadius: 'lg' + * }); + * ``` + */ export const focusRing = () => ({ outlineStyle: { default: 'none', @@ -78,6 +123,21 @@ const iconSizes = { XL: 26 } as const; +/** + * Generates styles for an icon element with the given size, color, and layout options. + * Must be imported with `{type: 'macro'}`. + * + * @param options - Icon styling options including `size` (XS–XL), `color`, and layout properties. + * @returns A `StyleString` that can be applied to an icon element. + * + * @example + * ```tsx + * import {iconStyle} from '@react-spectrum/s2/style' with {type: 'macro'}; + * import Edit from '@react-spectrum/s2/icons/Edit'; + * + * + * ``` + */ export function iconStyle(this: MacroContext | void, options: IconStyle): StyleString> { let {size = 'M', color, ...styles} = options; diff --git a/packages/@react-spectrum/s2/style/runtime.ts b/packages/@react-spectrum/s2/style/runtime.ts index 370fcb7b675..752b5858728 100644 --- a/packages/@react-spectrum/s2/style/runtime.ts +++ b/packages/@react-spectrum/s2/style/runtime.ts @@ -36,6 +36,22 @@ import {StyleString} from './types'; // }; // } +/** + * Merges multiple style strings together, combining the CSS properties from each. + * Later styles take precedence over earlier ones for the same property. + * Useful for composing styles from multiple `style()` macro calls. + * + * @example + * ```tsx + * import {mergeStyles} from '@react-spectrum/s2'; + * import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const baseStyles = style({padding: 8}); + * const overrideStyles = style({padding: 16, color: 'heading'}); + * const merged = mergeStyles(baseStyles, overrideStyles); + * // merged has `padding: 16` and `color: heading`. + * ``` + */ export function mergeStyles(...styles: (StyleString | null | undefined)[]): StyleString { let definedStyles = styles.filter(Boolean) as StyleString[]; if (definedStyles.length === 1) { diff --git a/packages/@react-spectrum/s2/style/spectrum-theme.ts b/packages/@react-spectrum/s2/style/spectrum-theme.ts index b66fa550564..441be4938d3 100644 --- a/packages/@react-spectrum/s2/style/spectrum-theme.ts +++ b/packages/@react-spectrum/s2/style/spectrum-theme.ts @@ -194,6 +194,22 @@ class SpectrumColorProperty extends ArbitraryProperty { type BaseColor = keyof typeof baseColors; +/** + * Returns a set of stateful color token references for the default, hovered, focus-visible, + * and pressed states of a component. + * + * @param base - A Spectrum base color token name (e.g. `'gray-100'`, `'accent-900'`). + * @returns An object with `default`, `isHovered`, `isFocusVisible`, and `isPressed` color token references. + * + * @example + * ```tsx + * import {baseColor, style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * backgroundColor: baseColor('gray-100') + * }); + * ``` + */ export function baseColor(base: BaseColor | C): {default: C, isHovered: C, isFocusVisible: C, isPressed: C} { return { default: base as C, @@ -204,6 +220,24 @@ export function baseColor(base: BaseColor | C): {d } type SpectrumColor = Color | ArbitraryValue; + +/** + * Resolves a Spectrum color token name to a CSS color value string. + * Supports opacity modifiers via the `color/opacity` syntax. + * + * @param value - A Spectrum color token (e.g. `'gray-800'`, `'accent-900/50'`) or an arbitrary CSS color value. + * @returns A CSS color string. + * + * @example + * ```tsx + * import {color, style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * color: color('gray-800'), + * borderColor: color('accent-900/50') + * }); + * ``` + */ export function color(value: SpectrumColor): string { let arbitrary = parseArbitraryValue(value); if (arbitrary) { @@ -213,10 +247,44 @@ export function color(value: SpectrumColor): string { return colorTokenToString(resolveColorToken(baseColors[colorValue]), opacity); } +/** + * Produces a `light-dark()` CSS color value that resolves to different colors + * depending on the current color scheme. + * + * @param light - The color to use in light mode. + * @param dark - The color to use in dark mode. + * @returns A CSS `light-dark()` expression wrapped as an arbitrary style value. + * + * @example + * ```tsx + * import {lightDark, style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * backgroundColor: lightDark('gray-25', 'gray-900') + * }); + * ``` + */ export function lightDark(light: SpectrumColor, dark: SpectrumColor): `[${string}]` { return `[light-dark(${color(light)}, ${color(dark)})]`; } +/** + * Mixes two Spectrum colors by a given percentage using CSS `color-mix()` in sRGB color space. + * + * @param a - The first color. + * @param b - The second color. + * @param percent - The percentage of the second color in the mix (0–100). + * @returns A CSS `color-mix()` expression wrapped as an arbitrary style value. + * + * @example + * ```tsx + * import {colorMix, style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * backgroundColor: colorMix('accent-900', 'gray-25', 50) + * }); + * ``` + */ export function colorMix(a: SpectrumColor, b: SpectrumColor, percent: number): `[${string}]` { return `[color-mix(in srgb, ${color(a)}, ${color(b)} ${percent}%)]`; } @@ -227,6 +295,23 @@ interface LinearGradient { stops: [SpectrumColor, number][] } +/** + * Creates a linear gradient value for use with the `backgroundImage` style property. + * Each color stop is registered as a CSS custom property so gradients can transition smoothly. + * + * @param angle - The CSS gradient direction or angle (e.g. `'to bottom right'`, `'45deg'`). + * @param tokens - Gradient color stops as `[color, percent]` tuples. + * @returns A gradient descriptor wrapped for use in the style macro. + * + * @example + * ```tsx + * import {linearGradient, style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * backgroundImage: linearGradient('to bottom right', ['fuchsia-900', 0], ['indigo-900', 66], ['blue-900', 100]) + * }); + * ``` + */ export function linearGradient(this: MacroContext | void, angle: string, ...tokens: [SpectrumColor, number][]): [LinearGradient] { // Generate @property rules for each gradient stop color. This allows the gradient to be animated. let propertyDefinitions: string[] = []; @@ -346,6 +431,24 @@ const padding = { ...relativeSpacing }; +/** + * Converts a pixel value to a scalable CSS size expression using the Spectrum 2 scale factor. + * The result is a `calc()` expression that multiplies the rem-converted value by the current scale factor. + * The scale factor differs between touch and non-touch devices. + * + * @param px - The size in pixels. + * @returns A CSS `calc()` expression. + * + * @example + * ```tsx + * import {size, style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * width: size(200), + * height: size(48) + * }); + * ``` + */ export function size(this: MacroContext | void, px: number): `calc(${string})` { return `calc(${pxToRem(px)} * var(--s2-scale))`; } diff --git a/packages/@react-spectrum/s2/style/style-macro.ts b/packages/@react-spectrum/s2/style/style-macro.ts index 8286f4dfa8a..18b870ba6f0 100644 --- a/packages/@react-spectrum/s2/style/style-macro.ts +++ b/packages/@react-spectrum/s2/style/style-macro.ts @@ -863,6 +863,26 @@ class ConditionalRule extends GroupRule { } } +/** + * Injects a raw CSS string into the style system. The CSS is wrapped in a generated + * class name and placed within the specified `@layer`. Returns the generated class name. + * This is an escape hatch for advanced cases (e.g. pseudo selectors or features not yet + * available in the style macro API), and should be used sparingly. + * Must be imported with `{type: 'macro'}`. + * + * @param css - The raw CSS declarations to inject. + * @param layer - The CSS `@layer` to place the styles in. Defaults to `'_.a'`. + * @returns The generated class name that applies the styles. + * + * @example + * ```tsx + * import {raw} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = raw(` + * backdrop-filter: blur(8px); + * `); + * ``` + */ export function raw(this: MacroContext | void, css: string, layer = '_.a'): string { // Check if `this` is undefined, which means style was not called as a macro but as a normal function. // We also check if this is globalThis, which happens in non-strict mode bundles. @@ -893,6 +913,27 @@ export function raw(this: MacroContext | void, css: string, layer = '_.a'): stri return className; } +/** + * Defines a CSS `@keyframes` animation and returns the generated animation name. + * Must be imported with `{type: 'macro'}`. + * + * @param css - The keyframe rules (e.g. `from { ... } to { ... }`). + * @returns The generated animation name to use in CSS `animation` properties. + * + * @example + * ```tsx + * import {keyframes} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const fadeIn = keyframes(` + * from { opacity: 0; } + * to { opacity: 1; } + * `); + * + * const styles = style({ + * animation: fadeIn, + * }); + * ``` + */ export function keyframes(this: MacroContext | void, css: string): string { // Check if `this` is undefined, which means style was not called as a macro but as a normal function. // We also check if this is globalThis, which happens in non-strict mode bundles. diff --git a/packages/dev/parcel-packager-docs/DocsPackager.js b/packages/dev/parcel-packager-docs/DocsPackager.js index 8f243bd0ef7..8c3d9ebdfcd 100644 --- a/packages/dev/parcel-packager-docs/DocsPackager.js +++ b/packages/dev/parcel-packager-docs/DocsPackager.js @@ -369,6 +369,7 @@ function visitChildren(obj, fn) { return: fn(obj.return, 'return'), typeParameters: obj.typeParameters.map(i => fn(i, 'typeParameters')), description: obj.description, + examples: obj.examples, access: obj.access }; case 'interface': diff --git a/packages/dev/parcel-transformer-docs/DocsTransformer.js b/packages/dev/parcel-transformer-docs/DocsTransformer.js index 5cc8cca9b2b..1797cd8f5fe 100644 --- a/packages/dev/parcel-transformer-docs/DocsTransformer.js +++ b/packages/dev/parcel-transformer-docs/DocsTransformer.js @@ -767,6 +767,10 @@ module.exports = new Transformer({ let result = { description: parsed.description }; + let extractedExamples = extractExamples(comments); + if (extractedExamples.length > 0) { + result.examples = extractedExamples; + } for (let tag of parsed.tags) { if (tag.title === 'default') { @@ -789,15 +793,74 @@ module.exports = new Transformer({ result.params[tag.name] = tag.description; } else if (tag.title === 'selector') { result.selector = tag.description; + } else if (tag.title === 'example') { + if (!result.examples) { + result.examples = []; + } + + if (tag.description) { + result.examples.push(tag.description); + } } } + if (result.examples) { + result.examples = [...new Set(result.examples.map(example => example.trim()).filter(Boolean))]; + } + return result; } return {}; } + function extractExamples(comments) { + let lines = comments.split('\n') + .map(line => line.replace(/^\s*\*?\s?/, '')); + let examples = []; + let current = null; + + for (let line of lines) { + if (/^@example\b/.test(line)) { + if (current) { + let prev = current.join('\n').trim(); + if (prev) { + examples.push(prev); + } + } + + current = []; + let inlineExample = line.replace(/^@example\b\s*/, ''); + if (inlineExample) { + current.push(inlineExample); + } + continue; + } + + if (current) { + if (/^@\w+/.test(line)) { + let example = current.join('\n').trim(); + if (example) { + examples.push(example); + } + current = null; + continue; + } + + current.push(line); + } + } + + if (current) { + let example = current.join('\n').trim(); + if (example) { + examples.push(example); + } + } + + return examples; + } + function getDocComments(path) { if (path.node.leadingComments) { return path.node.leadingComments.filter(isJSDocComment).map(c => c.value).join('\n'); @@ -857,6 +920,10 @@ module.exports = new Transformer({ if (value.return) { value.return.description = docs.return || value.return.description || null; } + + if (docs.examples) { + value.examples = docs.examples; + } } asset.type = 'json'; diff --git a/packages/dev/s2-docs/pages/s2/style-macro.mdx b/packages/dev/s2-docs/pages/s2/style-macro.mdx index 0e23cc5968d..31e88deb1ea 100644 --- a/packages/dev/s2-docs/pages/s2/style-macro.mdx +++ b/packages/dev/s2-docs/pages/s2/style-macro.mdx @@ -1,6 +1,9 @@ import {Layout} from '../../src/Layout'; import {InlineAlert, Heading, Content, Link} from '@react-spectrum/s2'; +import {FunctionJSDoc} from '../../src/FunctionJSDoc'; import {StyleMacroProperties} from '../../src/StyleMacroProperties'; +import docs from 'docs:@react-spectrum/s2'; +import styleDocs from 'docs:@react-spectrum/s2/style'; import {getPropertyDefinitions} from '../../src/styleProperties'; export default Layout; @@ -55,3 +58,68 @@ Note that `font` should be applied on a per element basis rather than globally s ## Conditions + +## Utilities + +The style macro system provides built-in utility functions for common patterns. + +### baseColor + + + +### color + + + +### lightDark + + + +### colorMix + + + +### linearGradient + + + +### size + + + +### space + + + +### fontRelative + + + +### focusRing + + + +### iconStyle + + +See the [Icons](icons#api) page for more information. + +### raw + + + +### keyframes + + + +### mergeStyles + + + +### centerPadding + + + +### setColorScheme + + diff --git a/packages/dev/s2-docs/pages/s2/styling.mdx b/packages/dev/s2-docs/pages/s2/styling.mdx index f791f8fb97b..3b2f2bd39bc 100644 --- a/packages/dev/s2-docs/pages/s2/styling.mdx +++ b/packages/dev/s2-docs/pages/s2/styling.mdx @@ -238,21 +238,23 @@ const styles = style({ ### Built-in utilities -Use `focusRing()` to add the standard Spectrum focus ring. +The style macro system includes built-in utilities for common patterns like focus rings, color helpers, spacing, sizing, animations, and more. For example, use `focusRing()` to add the standard Spectrum focus ring to interactive components: ```tsx "use client"; import {style, focusRing} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Button} from '@react-spectrum/s2'; +import {Button} from 'react-aria-components'; const buttonStyle = style({ ...focusRing(), // ...other styles }); - + ``` +See the [Utilities](style-macro#utilities) section in the style macro reference page for a full list of available utilities and examples. + ## Setting CSS variables CSS variables can be directly defined in a `style` macro, allowing child elements to then access them in their own styles. diff --git a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs index f489adefc4c..22bd7f76b3f 100644 --- a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs +++ b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs @@ -42,6 +42,7 @@ function getBaseUrl(library) { 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'); +const S2_STYLE_ROOT = path.join(REPO_ROOT, 'packages/@react-spectrum/s2/style'); const RAC_SRC_ROOT = path.join(REPO_ROOT, 'packages/react-aria-components/src'); const INTL_SRC_ROOT = path.join(REPO_ROOT, 'packages/@internationalized'); const COMPONENT_SRC_ROOTS = [S2_SRC_ROOT, RAC_SRC_ROOT, INTL_SRC_ROOT]; @@ -65,6 +66,7 @@ const interfaceTableCache = new Map(); const classTableCache = new Map(); const propTableCache = new Map(); const descriptionCache = new Map(); +const functionExamplesCache = new Map(); let tsFileIndex = null; let styleMacroDataCache = null; const styleMacroTableCache = new Map(); @@ -885,38 +887,101 @@ function extractJSXText(node, file) { return ''; } -function getRootsForFile(file) { +function getCacheKey(name, file) { if (file?.path) { if (file.path.includes(path.join('pages', 'react-aria', 'internationalized'))) { - return [INTL_SRC_ROOT, S2_SRC_ROOT, RAC_SRC_ROOT]; + return `intl:${name}`; } else if (file.path.includes(path.join('pages', 'react-aria'))) { - return [RAC_SRC_ROOT, S2_SRC_ROOT, INTL_SRC_ROOT]; + return `rac:${name}`; + } else if (file.path.includes(path.join('pages', 's2'))) { + return `s2:${name}`; } } - return COMPONENT_SRC_ROOTS; + return `default:${name}`; } -function getCacheKey(name, file) { +function getDocsImportSource(identifier, file) { + return file?.data?.docsImports?.[identifier] || null; +} + +function getExistingRoots(roots) { + return [...new Set(roots.filter(root => root && fs.existsSync(root)))]; +} + +function getRootsForDocsSource(docsSource, file) { + if (!docsSource) { + return null; + } + + if (docsSource === '@react-spectrum/s2') { + return getExistingRoots([S2_SRC_ROOT, S2_STYLE_ROOT]); + } + + if (docsSource === '@react-spectrum/s2/style') { + return getExistingRoots([S2_STYLE_ROOT]); + } + + if (docsSource.startsWith('./') || docsSource.startsWith('../')) { + if (!file?.path) { + return null; + } + + const resolved = path.resolve(path.dirname(file.path), docsSource); + const candidates = [ + resolved, + `${resolved}.ts`, + `${resolved}.tsx`, + `${resolved}.d.ts`, + path.join(resolved, 'index.ts'), + path.join(resolved, 'index.tsx'), + path.join(resolved, 'index.d.ts') + ]; + + return getExistingRoots(candidates.map(candidate => { + if (!fs.existsSync(candidate)) { + return null; + } + + return fs.statSync(candidate).isDirectory() ? candidate : path.dirname(candidate); + })); + } + + const packagePath = path.join(REPO_ROOT, 'packages', docsSource); + if (fs.existsSync(packagePath)) { + if (fs.statSync(packagePath).isDirectory()) { + return getExistingRoots([path.join(packagePath, 'src'), packagePath]); + } + + return getExistingRoots([path.dirname(packagePath)]); + } + + return null; +} + +function getRootsForFile(file, docsSource) { + const docsRoots = getRootsForDocsSource(docsSource, file); + if (docsRoots?.length) { + return docsRoots; + } + if (file?.path) { if (file.path.includes(path.join('pages', 'react-aria', 'internationalized'))) { - return `intl:${name}`; + return [INTL_SRC_ROOT, S2_SRC_ROOT, RAC_SRC_ROOT]; } else if (file.path.includes(path.join('pages', 'react-aria'))) { - return `rac:${name}`; - } else if (file.path.includes(path.join('pages', 's2'))) { - return `s2:${name}`; + return [RAC_SRC_ROOT, S2_SRC_ROOT, INTL_SRC_ROOT]; } } - return `default:${name}`; + return COMPONENT_SRC_ROOTS; } -function resolveComponentPath(componentName, file) { +function resolveComponentPath(componentName, file, docsSource) { // Check unified cache first - const cacheKey = getCacheKey(componentName, file); + const cacheKey = getCacheKey(`${docsSource || 'default'}:${componentName}:path`, file); if (interfacePathCache.has(cacheKey)) { return interfacePathCache.get(cacheKey); } - - let roots = getRootsForFile(file); + + let roots = getRootsForFile(file, docsSource); // Fast path: check direct file paths first for (let root of roots) { @@ -969,14 +1034,14 @@ function resolveComponentPath(componentName, file) { /** * Extract the leading JSDoc description comment placed immediately above the export for a component. */ -function getComponentDescription(componentName, file) { +function getComponentDescription(componentName, file, docsSource) { // Check cache first - const cacheKey = getCacheKey(componentName, file); + const cacheKey = getCacheKey(`${docsSource || 'default'}:${componentName}:description`, file); if (descriptionCache.has(cacheKey)) { return descriptionCache.get(cacheKey); } - const componentPath = resolveComponentPath(componentName, file); + const componentPath = resolveComponentPath(componentName, file, docsSource); if (!componentPath) { descriptionCache.set(cacheKey, null); return null; @@ -1071,6 +1136,154 @@ function shouldOmitSymbol(sym) { }); } +/** + * Extracts one or more `@example` tag contents from JSDoc comments. + * @param {string} text - The text to extract examples from. + * @returns {string[]} An array of examples. + */ +function extractExamplesFromText(text) { + if (!text || typeof text !== 'string') { + return []; + } + + let lines = text.split('\n').map(line => line.replace(/^\s*\*?\s?/, '')); + let examples = []; + let current = null; + + for (let line of lines) { + if (/^@example\b/.test(line)) { + if (current) { + let prev = current.join('\n').trim(); + if (prev) { + examples.push(prev); + } + } + + current = []; + let inlineExample = line.replace(/^@example\b\s*/, ''); + if (inlineExample) { + current.push(inlineExample); + } + continue; + } + + if (current) { + if (/^@\w+/.test(line)) { + let example = current.join('\n').trim(); + if (example) { + examples.push(example); + } + current = null; + continue; + } + + current.push(line); + } + } + + if (current) { + let example = current.join('\n').trim(); + if (example) { + examples.push(example); + } + } + + return examples; +} + +function parseFencedCodeBlock(example) { + if (!example || typeof example !== 'string') { + return null; + } + + let trimmed = example.trim(); + let match = trimmed.match(/^```([^\n`]*)\n([\s\S]*?)\n```$/); + if (!match) { + return null; + } + + let [, lang, code] = match; + return { + lang: lang?.trim() || undefined, + code + }; +} + +function getFunctionExamples(functionName, file, docsSource) { + const cacheKey = getCacheKey(`${docsSource || 'default'}:${functionName}:examples`, file); + if (functionExamplesCache.has(cacheKey)) { + return functionExamplesCache.get(cacheKey); + } + + const functionPath = resolveComponentPath(functionName, file, docsSource); + if (!functionPath) { + functionExamplesCache.set(cacheKey, []); + return []; + } + + const source = project.addSourceFileAtPathIfExists(functionPath); + if (!source) { + functionExamplesCache.set(cacheKey, []); + return []; + } + + const exportedDecl = source.getExportedDeclarations().get(functionName)?.[0]; + const possibleNodes = [exportedDecl, source.getVariableDeclaration(functionName), source.getFunction(functionName)]; + + let firstNodeExamples = []; + for (let node of possibleNodes.filter(Boolean)) { + let current = node; + let isDirectNode = true; + + while (current) { + let docs = typeof current.getJsDocs === 'function' ? current.getJsDocs() : []; + if (!docs?.length) { + isDirectNode = false; + current = current.getParent?.(); + continue; + } + + let directExamples = []; + for (let doc of docs) { + let tags = doc.getTags?.() || []; + let tagExamples = tags + .filter(tag => tag.getTagName?.() === 'example') + .map(tag => tag.getCommentText?.()) + .filter(Boolean) + .map(value => value.trim()); + directExamples.push(...tagExamples); + + if (tagExamples.length === 0) { + let docText = doc.getInnerText?.() || doc.getText?.() || ''; + directExamples.push(...extractExamplesFromText(docText)); + } + } + + directExamples = [...new Set(directExamples.filter(Boolean))]; + if (!directExamples.length) { + isDirectNode = false; + current = current.getParent?.(); + continue; + } + + if (isDirectNode) { + functionExamplesCache.set(cacheKey, directExamples); + return directExamples; + } + + if (!firstNodeExamples.length) { + firstNodeExamples = directExamples; + } + + isDirectNode = false; + current = current.getParent?.(); + } + } + + functionExamplesCache.set(cacheKey, firstNodeExamples); + return firstNodeExamples; +} + /** * Build a markdown table of props for the given component by analyzing its interface. */ @@ -1302,11 +1515,38 @@ function generateInterfaceTable(interfaceName, file) { * Custom remark plugin that removes MDX import/export statements. */ function remarkRemoveImportsExports() { - return (tree) => { + return (tree, file) => { + let docsImports = {}; visit(tree, 'mdxjsEsm', (node, index, parent) => { + if (node.value) { + try { + const ast = babel.parse(node.value, { + sourceType: 'module', + plugins: ['jsx', 'typescript'] + }); + + for (const statement of ast.program.body) { + if (statement.type !== 'ImportDeclaration' || typeof statement.source.value !== 'string' || !statement.source.value.startsWith('docs:')) { + continue; + } + + const docsSource = statement.source.value.slice(5); + for (const specifier of statement.specifiers) { + if (specifier.local?.name) { + docsImports[specifier.local.name] = docsSource; + } + } + } + } catch { + // Ignore non-import ESM blocks. + } + } + parent.children.splice(index, 1); return index; }); + + file.data.docsImports = docsImports; }; } @@ -1515,6 +1755,63 @@ function remarkDocsComponentsToMarkdown() { return index; } + // Render function description + examples from JSDoc. + if (name === 'FunctionJSDoc') { + const functionAttr = node.attributes?.find((a) => a.name === 'function'); + let functionName = null; + let docsSource = null; + if (functionAttr && functionAttr.value?.type === 'mdxJsxAttributeValueExpression') { + const m = functionAttr.value.value.match(/^([\w$]+)\.exports\.([\w$]+)$/); + if (m) { + docsSource = getDocsImportSource(m[1], file); + functionName = m[2]; + } else { + const fallback = functionAttr.value.value.match(/\.exports\.([\w$]+)/); + if (fallback) { + functionName = fallback[1]; + } + } + } + + if (!functionName) { + parent.children.splice(index, 1); + return index; + } + + const newNodes = []; + const description = getComponentDescription(functionName, file, docsSource); + if (description) { + const descTree = unified().use(remarkParse).parse(description); + newNodes.push(...descTree.children); + } + + const examples = getFunctionExamples(functionName, file, docsSource); + for (let [exampleIndex, example] of examples.entries()) { + if (examples.length > 1) { + newNodes.push({ + type: 'paragraph', + children: [{type: 'strong', children: [{type: 'text', value: `Example ${exampleIndex + 1}:`}]}] + }); + } + + const fenced = parseFencedCodeBlock(example); + if (fenced) { + newNodes.push({ + type: 'code', + lang: fenced.lang || 'tsx', + meta: '', + value: fenced.code + }); + } else { + const exampleTree = unified().use(remarkParse).parse(example); + newNodes.push(...exampleTree.children); + } + } + + parent.children.splice(index, 1, ...newNodes); + return index + newNodes.length; + } + // Render a table of props. if (name === 'PropTable') { const compAttr = node.attributes?.find((a) => a.name === 'component'); diff --git a/packages/dev/s2-docs/src/FunctionJSDoc.tsx b/packages/dev/s2-docs/src/FunctionJSDoc.tsx new file mode 100644 index 00000000000..771b0cd56cc --- /dev/null +++ b/packages/dev/s2-docs/src/FunctionJSDoc.tsx @@ -0,0 +1,69 @@ +/* + * 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 {Code} from './Code'; +import React from 'react'; +import {renderHTMLfromMarkdown} from './types'; +import {standaloneCode} from './CodeBlock'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +interface FunctionJSDocProps { + function: { + description?: string | null, + examples?: string[] + } +} + +function parseFencedCodeBlock(example: string): {lang?: string, code: string} | null { + let trimmed = example.trim(); + let match = trimmed.match(/^```([^\n`]*)\n([\s\S]*?)\n```$/); + if (!match) { + return null; + } + + let [, lang, code] = match; + return { + lang: lang?.trim() || undefined, + code + }; +} + +export function FunctionJSDoc({function: func}: FunctionJSDocProps) { + let examples = Array.isArray(func.examples) + ? func.examples.filter(Boolean) + : []; + + return ( +
+ {renderHTMLfromMarkdown(func.description, {forceInline: false, forceBlock: true})} + {examples.map((example, index) => { + let parsedExample = parseFencedCodeBlock(example); + return ( +
+ {examples.length > 1 && + + Example {index + 1}: + + } + {parsedExample + ? ( +
+                  {parsedExample.code}
+                
+ ) + : renderHTMLfromMarkdown(example, {forceInline: false, forceBlock: true})} +
+ ); + })} +
+ ); +}