From 059134e8f7d1ff38dc72af9a1b7c2cb00eb6597a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 12 Aug 2026 19:46:25 +0000 Subject: [PATCH 01/10] feat: Update AttachmentList to support Illustrations for preview and update invalid styling for preview only (#10452) * support illustration in attachment and center when preview only * switch to only use Illustration * adjusting size of preview only illustration and reducing gapbetween illustration and text * handle preview only invalid better, in progress case with illustration, get rid of reliance on horizontal card * delete horizontal card stories * ignore codex config for scout * review updates * delete horizontal card --- .gitignore | 1 + .../@react-spectrum/ai/src/AttachmentList.tsx | 331 ++++++- .../@react-spectrum/ai/src/HorizontalCard.tsx | 890 ------------------ .../ai/stories/AttachmentList.stories.tsx | 108 ++- .../stories/BasicHorizontalCard.stories.tsx | 146 --- .../ai/stories/HorizontalCard.stories.tsx | 112 --- .../ai/stories/PromptField.stories.tsx | 43 +- 7 files changed, 416 insertions(+), 1215 deletions(-) delete mode 100644 packages/@react-spectrum/ai/src/HorizontalCard.tsx delete mode 100644 packages/@react-spectrum/ai/stories/BasicHorizontalCard.stories.tsx delete mode 100644 packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx diff --git a/.gitignore b/.gitignore index ac8c322e491..a96bd943803 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ starters/tailwind/registry starters/docs/yarn.lock starters/tailwind/yarn.lock .scout/ +.codex/ diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index da3e9cb4f49..cd36b61eb62 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -20,23 +20,27 @@ import { } from '@react-types/shared'; import { baseColor, + color, focusRing, iconStyle, + lightDark, + space, style } from '@react-spectrum/s2/style' with {type: 'macro'}; -import {BasicHorizontalCard} from './HorizontalCard'; import {Button} from 'react-aria-components/Button'; import {CardProps} from '@react-spectrum/s2/Card'; +import {ContentContext} from '@react-spectrum/s2/Content'; import Cross from '../ui-icons/Cross'; +import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; import {forwardRef, ReactNode, useContext, useRef} from 'react'; -import {IconContext} from '@react-spectrum/s2/Icon'; +import {IllustrationContext} from '@react-spectrum/s2/Icon'; import {ImageContext} from '@react-spectrum/s2/Image'; +import {ImageCoordinator} from '@react-spectrum/s2/ImageCoordinator'; // @ts-ignore import intlMessages from '../intl/*.json'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; -import {Provider} from 'react-aria-components/slots'; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { Tag, @@ -46,6 +50,7 @@ import { TagListProps, TagProps } from 'react-aria-components/TagGroup'; +import {TextContext} from '@react-spectrum/s2/Text'; import {useDOMRef} from './useDOMRef'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; @@ -111,6 +116,183 @@ const styles = style<{ disableTapHighlight: true }); +const onlyPreview = ':not(:has([data-slot=content])):not(:has([data-slot=preview]))'; +const noDescription = ':not(:has([slot=description]))'; + +const attachmentCard = style({ + display: 'flex', + flexDirection: 'row', + position: 'relative', + borderRadius: 'default', + backgroundColor: { + default: lightDark('transparent-white-300', 'transparent-black-300'), + forcedColors: 'ButtonFace' + }, + boxShadow: { + default: `[inset 0 0 0 1px light-dark(${color('transparent-black-300')}, ${color('transparent-white-300')})]`, + isInvalid: `[inset 0 0 0 1px ${color('negative-900')}]` + }, + forcedColorAdjust: 'none', + transition: 'default', + fontFamily: 'sans', + overflow: 'clip', + contain: 'layout', + disableTapHighlight: true, + height: { + default: 68, + size: { + XS: 52, + S: 60, + M: 68, + L: 76, + XL: 80 + } + }, + width: { + default: 'full', + [onlyPreview]: 'auto' + }, + aspectRatio: { + [onlyPreview]: '1/1' + }, + '--card-spacing': { + type: 'paddingTop', + value: { + size: { + XS: 8, + S: 12, + M: 16, + L: 20, + XL: 24 + }, + [onlyPreview]: 0 + } + }, + alignItems: 'center', + + '--card-padding-y': { + type: 'paddingTop', + value: {default: '--card-spacing'} + }, + '--card-padding-x': { + type: 'paddingStart', + value: {default: '--card-spacing'} + }, + paddingY: '--card-padding-y', + paddingX: '--card-padding-x', + boxSizing: 'border-box', + justifyContent: { + [onlyPreview]: 'center' + }, + '--basic-thumb-size': { + type: 'height', + value: { + size: { + XS: 24, + S: 26, + M: 32, + L: 36, + XL: 40 + }, + [onlyPreview]: 'full' + } + }, + '--illust-thumb-size': { + type: 'height', + value: { + size: { + XS: 48, + S: 44, + M: 48, + L: 52, + XL: 56 + }, + [onlyPreview]: 'full' + } + }, + '--illust-margin-x': { + type: 'marginStart', + value: { + size: { + XS: -8, + S: -8, + M: -12, + L: -12, + XL: -12 + } + } + } +}); + +const illustThumbnailStyles = style({ + position: 'relative', + alignSelf: 'center', + flexShrink: 0, + pointerEvents: 'none', + userSelect: 'none', + size: '--illust-thumb-size', + marginX: '--illust-margin-x' +}); + +const attachmentTitle = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'}>({ + font: 'title', + fontSize: { + size: { + XS: 'title-xs', + S: 'title-xs', + M: 'title-sm', + L: 'title', + XL: 'title-lg' + } + }, + lineClamp: 1, + gridArea: 'title' +}); + +const attachmentDescription = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'}>({ + font: 'body', + fontSize: { + size: { + XS: 'body-2xs', + S: 'body-2xs', + M: 'body-xs', + L: 'body-sm', + XL: 'body' + } + }, + lineClamp: 1, + gridArea: 'description' +}); + +const attachmentContent = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'}>({ + display: 'grid', + gridTemplateColumns: ['minmax(0, 1fr)'], + gridTemplateAreas: ['title', 'description'], + columnGap: 4, + flexGrow: 1, + minWidth: 0, + alignItems: 'baseline', + alignContent: 'start', + rowGap: { + size: { + XS: 4, + S: 4, + M: space(6), + L: space(6), + XL: 8 + }, + [noDescription]: 0 + }, + paddingStart: { + default: '--card-spacing', + ':first-child': 0 + }, + paddingEnd: { + default: 'calc(var(--card-spacing) * 1.5 / 2)', + ':last-child': 0 + } +}); + const CloseButton = function CloseButton(props) { let ref = useRef(null); // oxlint-disable react/react-compiler @@ -208,32 +390,63 @@ const tagStyles = style({ borderRadius: 'default' }); +// this is checking that there isn't content in the attachment +// similar to onlyPreview, but specifically checking siblings before the alert icon (aka looking for Content) +const onlyPreviewFromError = ':not([data-slot=content] ~ *)'; const attachmentErrorStyles = style({ display: 'flex', flexShrink: 0, alignItems: 'center', - paddingStart: 8, + paddingStart: { + default: 8, + [onlyPreviewFromError]: 0 + }, + position: { + [onlyPreviewFromError]: 'absolute' + }, + top: { + [onlyPreviewFromError]: '50%' + }, + insetStart: { + [onlyPreviewFromError]: '50%' + }, + transform: { + [onlyPreviewFromError]: 'translate(-50%, -50%)' + }, '--iconPrimary': { type: 'color', value: 'negative' } }); +// this is also checking that there isn't content in the attachment +// similar to onlyPreview, but specifically checking siblings after the thumbnail (aka looking for Content) +const onlyPreviewFromThumbnail = ':not(:has(~ [data-slot=content]))'; function AttachmentContextProvider({ children, - isUploading + isUploading, + isInvalid }: { children: ReactNode; isUploading: boolean; + isInvalid?: boolean; }) { let imageCtx = useContext(ImageContext); - let iconCtx = useContext(IconContext); + let illustrationCtx = useContext(IllustrationContext); const opacityStyles = style({ - opacity: {default: 1, isUploading: 0.15}, + opacity: { + default: 1, + isUploading: 0.15, + isInvalid: { + default: 1, + [onlyPreviewFromThumbnail]: 0.15 + } + }, transition: 'default' - })({isUploading}); + })({isUploading, isInvalid}); const imageSlots = imageCtx && 'slots' in imageCtx ? imageCtx.slots : undefined; - const iconSlots = iconCtx && 'slots' in iconCtx ? iconCtx.slots : undefined; + const illustrationSlots = + illustrationCtx && 'slots' in illustrationCtx ? illustrationCtx.slots : undefined; return ( + + {children} + + + ); +} + export const Attachment = forwardRef(function Attachment( props: AttachmentProps, ref: DOMRef @@ -282,8 +571,7 @@ export const Attachment = forwardRef(function Attachment( styles, isInvalid, children, - size = 'M', - ...otherProps + size = 'M' } = props; let domRef = useDOMRef(ref); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); @@ -296,7 +584,7 @@ export const Attachment = forwardRef(function Attachment( aria-describedby={ariaDescribedby} ref={domRef} className={renderProps => mergeStyles(tagStyles({...renderProps}), styles)}> - + {props.uploadProgress != null && props.uploadProgress < 100 && (
@@ -319,6 +611,7 @@ export const Attachment = forwardRef(function Attachment(
)} {typeof children === 'function' ? children({size}) : children} @@ -327,7 +620,7 @@ export const Attachment = forwardRef(function Attachment( )} -
+ {/** Definitely not a close button, though looks like one. */}
{ - /** The children of the Card. */ - children: ReactNode | ((renderProps: HorizontalCardRenderProps) => ReactNode); - /** - * The size of the Card. - * - * @default 'M' - */ - size?: 'XS' | 'S' | 'M' | 'L' | 'XL'; - /** - * The amount of internal padding within the Card. - * - * @default 'regular' - */ - density?: 'compact' | 'regular' | 'spacious'; - /** - * The visual style of the Card. - * - * @default 'primary' - */ - variant?: 'primary' | 'secondary' | 'tertiary'; - /** - * Spectrum-defined styles, returned by the `style()` macro. - */ - styles?: StyleString; -} - -export interface BasicCardProps extends Omit { - /** - * The visual style of the Card. - * - * @default 'primary' - */ - variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet'; - /** Whether the card is in an error state. */ - isInvalid?: boolean; -} - -const borderRadius = { - default: 'lg', - size: { - XS: 'default', - S: 'default' - }, - isBasic: 'default' -} as const; - -// Figma missing a lot of combinations of variant, tshirt, density -// Quiet Basic cards? -// Does Basic not participate in selection? (It does, but it's denoted by the border...) -// Why is there a flipped horizontal card? -// Max width on contents for horizontal cards? Doesn't appear to be one that includes the preview because the preview can have any ratio and that -// causes the width grow. -// (Max) height on cards? Maybe that makes more sense. - -const onlyPreview = ':not(:has([data-slot=content])):not(:has([data-slot=preview]))'; - -let card = style({ - display: 'flex', - flexDirection: 'row', - position: 'relative', - borderRadius, - backgroundColor: { - default: lightDark('transparent-white-300', 'transparent-black-300'), - forcedColors: 'ButtonFace' - }, - // TODO: waiting for design to investigate thumbnail card/attachement error state - boxShadow: { - default: `[inset 0 0 0 1px light-dark(${color('transparent-black-300')}, ${color('transparent-white-300')})]`, - isInvalid: `[inset 0 0 0 1px ${color('negative-900')}]` - }, - forcedColorAdjust: 'none', - transition: 'default', - fontFamily: 'sans', - textDecoration: 'none', - overflow: { - default: 'clip', - variant: { - quiet: 'visible' - } - }, - contain: 'layout', - disableTapHighlight: true, - userSelect: { - isCardView: 'none' - }, - cursor: { - isLink: 'pointer' - }, - height: { - default: { - size: { - XS: 160, - S: 180, - M: 200, - L: 220, - XL: 240 - } - }, - isBasic: { - default: 68, - size: { - XS: 52, - S: 60, - M: 68, - L: 76, - XL: 80 - } - }, - isCardView: 'full' - }, - width: { - default: 'full', - [onlyPreview]: 'auto' - }, - aspectRatio: { - [onlyPreview]: '1/1' - }, - '--card-spacing': { - type: 'paddingTop', - value: { - density: { - compact: { - size: { - XS: '[6px]', - S: 8, - M: 12, - L: 16, - XL: 20 - } - }, - regular: { - size: { - XS: 8, - S: 12, - M: 16, - L: 20, - XL: 24 - } - }, - spacious: { - size: { - XS: 12, - S: 16, - M: 20, - L: 24, - XL: 28 - } - } - }, - [onlyPreview]: 0 - } - }, - alignItems: { - isBasic: 'center' - }, - '--card-padding-y': { - type: 'paddingTop', - value: { - default: '--card-spacing' - } - }, - '--card-padding-x': { - type: 'paddingStart', - value: { - default: '--card-spacing' - } - }, - paddingY: '--card-padding-y', - paddingX: '--card-padding-x', - boxSizing: 'border-box', - ...focusRing(), - outlineStyle: { - default: 'none', - isFocusVisible: 'solid', - // Focus ring moves to preview when quiet. - variant: { - quiet: 'none' - } - }, - '--basic-thumb-size': { - type: 'height', - value: { - default: 68, - size: { - XS: 24, - S: 26, - M: 32, - L: 36, - XL: 40 - }, - [onlyPreview]: 'full' - } - } -}); - -let selectionIndicator = style({ - position: 'absolute', - inset: 0, - zIndex: 2, - borderRadius, - pointerEvents: 'none', - borderWidth: 2, - borderStyle: 'solid', - borderColor: 'gray-1000', - transition: 'default', - opacity: { - default: 0, - isSelected: 1 - }, - // Quiet cards with no checkbox have an extra inner stroke - // to distinguish the selection indicator from the preview. - outlineColor: lightDark('transparent-white-600', 'transparent-black-600'), - outlineOffset: -4, - outlineStyle: { - default: 'none', - isStrokeInner: 'solid' - }, - outlineWidth: 2 -}); - -let preview = style({ - position: 'relative', - transition: 'default', - overflow: 'clip', - marginY: 'calc(var(--card-padding-y) * -1)', - marginStart: 'calc(var(--card-padding-x) * -1)', - marginEnd: { - ':last-child': 'calc(var(--card-padding-x) * -1)' - }, - borderRadius: { - isQuiet: borderRadius - }, - boxShadow: { - isQuiet: { - isHovered: 'elevated', - isFocusVisible: 'elevated', - isSelected: 'elevated' - } - }, - ...focusRing(), - outlineStyle: { - default: 'none', - isQuiet: { - isFocusVisible: 'solid' - } - } -}); - -const image = style({ - height: 'full', - aspectRatio: '1/1', - objectFit: 'cover', - userSelect: 'none', - pointerEvents: 'none' -}); - -let title = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'; isBasic?: boolean}>({ - font: 'title', - fontSize: { - size: { - XS: 'title-xs', - S: 'title-xs', - M: 'title-sm', - L: 'title', - XL: 'title-lg' - } - }, - lineClamp: { - default: 3, - isBasic: 1 - }, - gridArea: 'title' -}); - -let description = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'; isBasic?: boolean}>({ - font: 'body', - fontSize: { - size: { - XS: 'body-2xs', - S: 'body-2xs', - M: 'body-xs', - L: 'body-sm', - XL: 'body' - } - }, - lineClamp: { - default: 3, - isBasic: 1 - }, - gridArea: 'description' -}); - -let content = style({ - display: 'grid', - // By default, all elements are displayed in a stack. - // If an action menu is present, place it next to the title. - gridTemplateColumns: { - default: ['minmax(0, 1fr)'], - ':has([data-slot=menu])': ['minmax(0, 1fr)', 'auto'] - }, - gridTemplateAreas: { - default: ['title', 'description'], - ':has([data-slot=menu])': ['title menu', 'description description'] - }, - columnGap: 4, - flexGrow: 1, - minWidth: 0, - alignItems: 'baseline', - alignContent: 'start', - rowGap: { - size: { - XS: 4, - S: 4, - M: space(6), - L: space(6), - XL: 8 - } - }, - paddingStart: { - default: '--card-spacing', - ':first-child': 0 - }, - paddingEnd: { - default: 'calc(var(--card-spacing) * 1.5 / 2)', - ':last-child': 0 - } -}); - -let actionMenu = style({ - gridArea: 'menu', - // Don't cause the row to expand, preserve gap between title and description text. - // Would use -100% here but it doesn't work in Firefox. - marginY: 'calc(-1 * self(height))' -}); - -let footer = style({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 8 -}); - -export const InternalCardViewContext = createContext({ - ElementType: 'div' as 'div' | typeof GridListItem, - layout: 'grid' as 'grid' | 'waterfall' -}); - -interface InternalCardContextValue { - isQuiet: boolean; - size: 'XS' | 'S' | 'M' | 'L' | 'XL'; - isSelected: boolean; - isHovered: boolean; - isFocusVisible: boolean; - isPressed: boolean; - isCheckboxSelection: boolean; -} - -const InternalCardContext = createContext({ - isQuiet: false, - size: 'M', - isSelected: false, - isHovered: false, - isFocusVisible: false, - isPressed: false, - isCheckboxSelection: true -}); - -const actionButtonSize = { - XS: 'XS', - S: 'XS', - M: 'S', - L: 'M', - XL: 'L' -} as const; - -const Card = forwardRef(function Card( - props: Omit & { - isBasic?: boolean; - isInvalid?: boolean; - variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet'; - }, - ref: DOMRef -) { - let {ElementType} = useContext(InternalCardViewContext); - let domRef = useDOMRef(ref); - let { - isBasic = false, - isInvalid = false, - density = 'regular', - size = 'M', - variant = 'primary', - styles, - id, - ...otherProps - } = props; - let isQuiet = variant === 'quiet'; - let isSkeleton = useIsSkeleton(); - let children = ( - - - {typeof props.children === 'function' ? props.children({size}) : props.children} - - - ); - - // oxlint-disable-next-line react/react-compiler - let press = pressScale(domRef); - if (ElementType === 'div' && !isSkeleton && props.href) { - // Standalone Card that has an href should be rendered as a Link. - // NOTE: In this case, the card must not contain interactive elements. - return ( - - mergeStyles( - card({ - ...renderProps, - size, - density, - variant, - isBasic, - isInvalid, - isCardView: false, - isLink: true - }), - styles - ) - } - style={renderProps => - // Only the preview in quiet cards scales down on press - variant === 'quiet' ? undefined : press(renderProps) - }> - {renderProps => ( - - {children} - - )} - - ); - } - - if (ElementType === 'div' || isSkeleton) { - return ( -
- - {children} - -
- ); - } - - return ( - - mergeStyles( - card({ - ...renderProps, - isCardView: true, - isLink: !!props.href, - size, - density, - variant, - isBasic, - isInvalid - }), - styles - ) - } - style={renderProps => - // Only the preview in quiet cards scales down on press - variant === 'quiet' ? undefined : press(renderProps) - }> - {({selectionMode, selectionBehavior, isHovered, isFocusVisible, isSelected, isPressed}) => ( - - {/* Selection indicator and checkbox move inside the preview for quiet cards */} - {!isQuiet && } - {!isQuiet && selectionMode !== 'none' && selectionBehavior === 'toggle' && ( - - )} - {/* this makes the :first-child selector work even with the checkbox */} -
{children}
-
- )} -
- ); -}); - -function SelectionIndicator() { - let {size, isSelected, isQuiet, isCheckboxSelection} = useContext(InternalCardContext); - return ( -
- ); -} - -function CardCheckbox() { - let {size} = useContext(InternalCardContext); - return ( -
- -
- ); -} - -export interface CardPreviewProps extends DOMProps { - children: ReactNode; - /** - * Spectrum-defined styles, returned by the `style()` macro. - */ - styles?: StyleString; -} - -// TODO: this should be the same component as the one in @react-spectrum/s2/Card -export const CardPreview = forwardRef(function CardPreview( - props: CardPreviewProps, - ref: DOMRef -) { - let {size, isQuiet, isHovered, isFocusVisible, isSelected, isPressed, isCheckboxSelection} = - useContext(InternalCardContext); - let domRef = useDOMRef(ref); - // oxlint-disable react/react-compiler - return ( -
- {isQuiet && } - {isQuiet && isCheckboxSelection && } -
- {props.children} -
-
- ); - // oxlint-enable react/react-compiler -}); - -const collection = style({ - display: 'grid', - gridTemplateColumns: 'repeat(3, 1fr)', - gap: { - default: 4, - size: { - XS: 2, - S: 2 - } - } -}); - -const collectionImage = style({ - width: 'full', - gridColumnEnd: { - ':nth-last-child(4):first-child': 'span 3' - }, - objectFit: 'cover', - pointerEvents: 'none', - userSelect: 'none' -}); - -export const CollectionCardPreview = forwardRef(function CollectionCardPreview( - props: CardPreviewProps, - ref: DOMRef -) { - let {size} = useContext(InternalCardContext)!; - return ( - -
- - {props.children} - -
-
- ); -}); - -const buttonSize = { - XS: 'S', - S: 'S', - M: 'M', - L: 'L', - XL: 'XL' -} as const; - -export const HorizontalCard = forwardRef(function HorizontalCard( - props: HorizontalCardProps, - ref: DOMRef -) { - let {size = 'M'} = props; - return ( - - {composeRenderProps(props.children, children => ( - - {children} - - ))} - - ); -}); - -const iconThumbnailStyles = style({ - position: 'relative', - alignSelf: 'center', - flexShrink: 0, - pointerEvents: 'none', - userSelect: 'none', - size: '--basic-thumb-size' -}); - -export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard( - props: BasicCardProps, - ref: DOMRef -) { - let {size = 'M'} = props; - return ( - - {composeRenderProps(props.children, children => ( - - {children} - - ))} - - ); -}); diff --git a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx index 57dc6287524..e4c9f865ec5 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx @@ -13,8 +13,9 @@ import {Attachment as AttachmentComponent, AttachmentList} from '../src/AttachmentList'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; import {Content} from '@react-spectrum/s2/Content'; -import File from '@react-spectrum/s2/icons/File'; -import FileText from '@react-spectrum/s2/icons/FileText'; +import FileTextIllustration from '../../s2/spectrum-illustrations/gradient/generic1/FileText'; +import FileVideo from '@react-spectrum/s2/illustrations/gradient/generic1/FileVideo'; +import FileZip from '@react-spectrum/s2/illustrations/gradient/generic1/FileZip'; import {Image} from '@react-spectrum/s2/Image'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -106,20 +107,19 @@ function NonImageAttachmentListRender(args) { let {isInvalid, size, uploadProgress, ...listArgs} = args; return ( - {/* TODO: what should the thumbnail only look like with icons */} - + - + notes.txt Plain text document @@ -130,7 +130,7 @@ function NonImageAttachmentListRender(args) { isInvalid={isInvalid} size={size} aria-label="data.csv"> - + data.csv @@ -165,3 +165,99 @@ export const LongContents: Story = { ) }; + +function MixedAttachments(args) { + let {isInvalid, size, uploadProgress, ...listArgs} = args; + + return ( +
+ + + + + + + + + + + + + + + + + + + banner.png + PNG image + + + + + + notes.txt + Plain text + + + + + + video.mp4 + MP4 + + + + + + debug.zip + ZIP + + + +
+ ); +} + +export const Mixed: Story = { + name: 'Mixed attachements', + render: args => +}; diff --git a/packages/@react-spectrum/ai/stories/BasicHorizontalCard.stories.tsx b/packages/@react-spectrum/ai/stories/BasicHorizontalCard.stories.tsx deleted file mode 100644 index 37290d5f691..00000000000 --- a/packages/@react-spectrum/ai/stories/BasicHorizontalCard.stories.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2024 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 {ActionButton} from '@react-spectrum/s2/ActionButton'; -import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; -import {type BasicCardProps, BasicHorizontalCard} from '../src/HorizontalCard'; -import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; -import ChevronRight from '@react-spectrum/s2/icons/ChevronRight'; -import {Content} from '@react-spectrum/s2/Content'; -import {Footer} from '@react-spectrum/s2/Footer'; -import {Image} from '@react-spectrum/s2/Image'; -import {MenuItem} from '@react-spectrum/s2/Menu'; -import type {Meta, StoryObj} from '@storybook/react'; -import {Skeleton} from '@react-spectrum/s2/Skeleton'; -import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Text} from '@react-spectrum/s2/Text'; - -const events = ['onAction']; - -const meta: Meta = { - component: BasicHorizontalCard, - parameters: { - layout: 'centered' - }, - tags: ['autodocs'], - args: { - isLoading: false, - ...getActionArgs(events) - }, - argTypes: { - ...categorizeArgTypes('Events', events), - href: {table: {disable: true}}, - download: {table: {disable: true}}, - hrefLang: {table: {disable: true}}, - referrerPolicy: {table: {disable: true}}, - rel: {table: {disable: true}}, - routerOptions: {table: {disable: true}}, - ping: {table: {disable: true}}, - target: {table: {disable: true}}, - value: {table: {disable: true}}, - textValue: {table: {disable: true}}, - onAction: {table: {disable: true, category: 'Events'}}, - isDisabled: {table: {disable: true}}, - children: {table: {disable: true}} - }, - decorators: (children, {args}) => ( - {children(args)} - ), - title: 'AI/BasicHorizontalCard' -}; - -export default meta; - -type BasicStory = StoryObj; - -export const Basic: BasicStory = { - render: args => ( -
- - - - Card title - Card description. - -
- - Test - -
-
- - - - Card title - Card description. - -
- - - -
-
- - - - Card title - Card description. - - - - - - Card title - Card description. - - - - - -
- ), - argTypes: { - variant: { - control: 'radio', - options: ['primary', 'secondary', 'tertiary', 'quiet'] - } - } -}; diff --git a/packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx b/packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx deleted file mode 100644 index 246f42c8188..00000000000 --- a/packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2024 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 {CardPreview, HorizontalCard, type HorizontalCardProps} from '../src/HorizontalCard'; -import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; -import {Content} from '@react-spectrum/s2/Content'; -import {Image} from '@react-spectrum/s2/Image'; -import type {Meta, StoryObj} from '@storybook/react'; -import {Skeleton} from '@react-spectrum/s2/Skeleton'; -import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Text} from '@react-spectrum/s2/Text'; - -const events = ['onAction']; - -const meta: Meta = { - component: HorizontalCard, - parameters: { - layout: 'centered' - }, - tags: ['autodocs'], - args: { - isLoading: false, - ...getActionArgs(events) - }, - argTypes: { - ...categorizeArgTypes('Events', events), - href: {table: {disable: true}}, - download: {table: {disable: true}}, - hrefLang: {table: {disable: true}}, - referrerPolicy: {table: {disable: true}}, - rel: {table: {disable: true}}, - routerOptions: {table: {disable: true}}, - ping: {table: {disable: true}}, - target: {table: {disable: true}}, - value: {table: {disable: true}}, - textValue: {table: {disable: true}}, - onAction: {table: {disable: true, category: 'Events'}}, - isDisabled: {table: {disable: true}}, - children: {table: {disable: true}} - }, - decorators: (children, {args}) => ( - {children(args)} - ), - title: 'AI/HorizontalCard' -}; - -export default meta; - -type Story = StoryObj; - -export const Horizontal: Story = { - render: args => ( -
- - - - - - - Card title - - Card description. Give a concise overview of the context or functionality that's - mentioned in the card title. - - - - - - - - - - - Card title - - - Card description. Give a concise overview of the context or functionality that's - mentioned in the card title. - - - -
- ) -}; diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index e8c004ba22d..ffd8099a434 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -315,46 +315,6 @@ function EverythingRender(args) { }); }; - let isFieldEmpty = (prompt: TokenFieldValue) => { - let text = prompt.toString(); - return text === '' && !text.includes('\n'); - }; - - // logic for using up/down arrow keys to fill field with previous prompts - let onKeyDown = (e: React.KeyboardEvent) => { - let canNavigate = historyIndexRef.current !== -1 || isFieldEmpty(value); - let history = historyRef.current; - if (!canNavigate || history.length === 0) { - return; - } - - if (e.key === 'ArrowUp') { - e.preventDefault(); - let nextIndex = - historyIndexRef.current === -1 - ? history.length - 1 - : Math.max(0, historyIndexRef.current - 1); - historyIndexRef.current = nextIndex; - isHistoryNavigating.current = true; - setValue(history[nextIndex]); - } else if (e.key === 'ArrowDown') { - if (historyIndexRef.current === -1) { - return; - } - e.preventDefault(); - let nextIndex = historyIndexRef.current + 1; - if (nextIndex >= history.length) { - historyIndexRef.current = -1; - isHistoryNavigating.current = true; - setValue(new PromptFieldValue([])); - } else { - historyIndexRef.current = nextIndex; - isHistoryNavigating.current = true; - setValue(history[nextIndex]); - } - } - }; - let handleChange = (newValue: TokenFieldValue) => { if (!isHistoryNavigating.current) { // if user edits the field, then we want to reset the index so up arrow starts from latest prompt again @@ -446,8 +406,7 @@ function EverythingRender(args) { } pixelLoader={data[args.pixelLoader]} placeholder={placeholder} - menuWidth={menuWidth} - onKeyDown={onKeyDown}> + menuWidth={menuWidth}> {segment => ( {icons[segment.value?.type]} From ab60b325a74c9064cb001736a989ef2d9db8e258 Mon Sep 17 00:00:00 2001 From: Sebastian Cao Date: Wed, 12 Aug 2026 20:25:56 +0000 Subject: [PATCH 02/10] fix: selectDate fails for dates outside the visible range when isDateUnavailable is set (#10328) * fix: selectDate not working with uncontrolled calendar and isDateUnavailable * simplify logic and add RAC test, fix lint, fix dates in tests * fix edge case * make ts a little smarter --------- Co-authored-by: Rob Snow Co-authored-by: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> --- .../@internationalized/date/src/queries.ts | 5 + .../test/Calendar.test.js | 122 ++++++++++++++++++ .../src/calendar/useCalendarState.ts | 4 +- .../test/calendar/useCalendarState.test.ts | 108 ++++++++++++++++ 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 packages/react-stately/test/calendar/useCalendarState.test.ts diff --git a/packages/@internationalized/date/src/queries.ts b/packages/@internationalized/date/src/queries.ts index 5c2876c0074..92638187708 100644 --- a/packages/@internationalized/date/src/queries.ts +++ b/packages/@internationalized/date/src/queries.ts @@ -348,6 +348,11 @@ export function getWeeksInMonth( } /** Returns the lesser of the two provider dates. */ +export function minDate(a: A, b: B): A | B; +export function minDate( + a?: A | null, + b?: B | null +): A | B | null | undefined; export function minDate( a?: A | null, b?: B | null diff --git a/packages/react-aria-components/test/Calendar.test.js b/packages/react-aria-components/test/Calendar.test.js index b1aa4e93c35..da3d4880591 100644 --- a/packages/react-aria-components/test/Calendar.test.js +++ b/packages/react-aria-components/test/Calendar.test.js @@ -501,6 +501,128 @@ describe('Calendar', () => { expect(cell).not.toHaveClass('selected'); }); + describe('selectDate', () => { + // Use a fixed date so the tests are deterministic regardless of the current date. + let focusedDate = new CalendarDate(2026, 4, 15); + + let SelectDateExample = () => { + let state = useContext(CalendarStateContext); + return ( + + + + + {state.value ? state.value.toString() : 'none'} + + ); + }; + + it('selects a date before the visible range when isDateUnavailable is provided', async () => { + let {getByRole, getAllByRole, getByTestId} = render( + false}> +
+ + + +
+ {date => } + +
+ ); + + // Navigate to the next month so the focused date is before the visible range. + await user.click(getAllByRole('button', {name: 'Next'})[0]); + + // Selecting a date before the visible range should still work. + await user.click(getByRole('button', {name: 'Select focused'})); + expect(getByTestId('selected-value')).toHaveTextContent(focusedDate.toString()); + + await user.click(getByRole('button', {name: 'Select one month before'})); + expect(getByTestId('selected-value')).toHaveTextContent( + focusedDate.subtract({months: 1}).toString() + ); + }); + + it('selects a date after the visible range when isDateUnavailable is provided', async () => { + let {getByRole, getByTestId} = render( + false}> +
+ + + +
+ {date => } + +
+ ); + + // Navigate to the previous month so the focused date is after the visible range. + await user.click(getByRole('button', {name: 'Previous'})); + + await user.click(getByRole('button', {name: 'Select focused'})); + expect(getByTestId('selected-value')).toHaveTextContent(focusedDate.toString()); + + await user.click(getByRole('button', {name: 'Select one month after'})); + expect(getByTestId('selected-value')).toHaveTextContent( + focusedDate.add({months: 1}).toString() + ); + }); + + it('selects the nearest earlier available date when the focused date is unavailable', async () => { + let {getByRole, getByTestId} = render( + d.day === focusedDate.day}> +
+ + + +
+ {date => } + +
+ ); + + // The focused date is unavailable, but the day before it is available. + await user.click(getByRole('button', {name: 'Select focused'})); + expect(getByTestId('selected-value')).toHaveTextContent( + focusedDate.subtract({days: 1}).toString() + ); + }); + + it('does not select anything when the focused date is unavailable and no earlier date in the visible range is available', async () => { + let {getByRole, getByTestId} = render( + d.day <= focusedDate.day}> +
+ + + +
+ {date => } + +
+ ); + + // Every day from the start of the visible month through the focused date is unavailable. + await user.click(getByRole('button', {name: 'Select focused'})); + expect(getByTestId('selected-value')).toHaveTextContent('none'); + }); + }); + it('should not modify selection when trying to select an unavailable date by keyboard', async () => { let calendar = renderCalendar({isDateUnavailable: d => d.day === 15}); let day16 = calendar.getByText('16'); diff --git a/packages/react-stately/src/calendar/useCalendarState.ts b/packages/react-stately/src/calendar/useCalendarState.ts index c5df20dbe91..3150bb785ed 100644 --- a/packages/react-stately/src/calendar/useCalendarState.ts +++ b/packages/react-stately/src/calendar/useCalendarState.ts @@ -33,6 +33,7 @@ import { GregorianCalendar, isEqualCalendar, isSameDay, + minDate, startOfMonth, startOfWeek, toCalendar, @@ -230,7 +231,8 @@ export function useCalendarState< function normalizeValue(newValue: CalendarDate) { let constrained = constrainValue(newValue, minValue, maxValue); - let prev = previousAvailableDate(constrained, startDate, isDateUnavailable); + let lowerBound = minValue ?? minDate(constrained, startDate); + let prev = previousAvailableDate(constrained, lowerBound, isDateUnavailable); if (!prev) { return null; } diff --git a/packages/react-stately/test/calendar/useCalendarState.test.ts b/packages/react-stately/test/calendar/useCalendarState.test.ts new file mode 100644 index 00000000000..ca5a365e4ab --- /dev/null +++ b/packages/react-stately/test/calendar/useCalendarState.test.ts @@ -0,0 +1,108 @@ +/* + * 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 {actHook as act, renderHook} from '@react-spectrum/test-utils-internal'; +import {CalendarDate, createCalendar} from '@internationalized/date'; +import {useCalendarState} from '../../src/calendar/useCalendarState'; + +describe('useCalendarState', () => { + describe('selectDate', () => { + // https://github.com/adobe/react-spectrum/issues/7779 + let selectedDate = new CalendarDate(2026, 4, 15); + + it('selects a date before the visible range when isDateUnavailable is provided', () => { + let {result} = renderHook(() => + useCalendarState({ + locale: 'en-US', + createCalendar, + isDateUnavailable: () => false, + defaultFocusedValue: selectedDate + }) + ); + + // Navigate to the next month so the selected date is before the visible range. + act(() => { + result.current.focusNextPage(); + }); + expect(result.current.visibleRange.start.compare(selectedDate)).toBeGreaterThan(0); + + // Selecting a date before the visible range should still work. + act(() => { + result.current.selectDate(selectedDate); + }); + + expect(result.current.value).not.toBeNull(); + expect(result.current.value!.compare(selectedDate)).toBe(0); + }); + + it('selects a date after the visible range when isDateUnavailable is provided', () => { + let {result} = renderHook(() => + useCalendarState({ + locale: 'en-US', + createCalendar, + isDateUnavailable: () => false, + defaultFocusedValue: selectedDate + }) + ); + + // Navigate to the previous month so the selected date is after the visible range. + act(() => { + result.current.focusPreviousPage(); + }); + expect(result.current.visibleRange.end.compare(selectedDate)).toBeLessThan(0); + + act(() => { + result.current.selectDate(selectedDate); + }); + + expect(result.current.value).not.toBeNull(); + expect(result.current.value!.compare(selectedDate)).toBe(0); + }); + + it('selects the nearest earlier available date when the selected date is unavailable', () => { + let {result} = renderHook(() => + useCalendarState({ + locale: 'en-US', + createCalendar, + isDateUnavailable: date => date.day === 15, + defaultFocusedValue: new CalendarDate(2026, 4, 20) + }) + ); + + // Day 15 is in the visible range, but unavailable. Day 14 is available. + act(() => { + result.current.selectDate(new CalendarDate(2026, 4, 15)); + }); + + expect(result.current.value).not.toBeNull(); + expect(result.current.value!.compare(new CalendarDate(2026, 4, 14))).toBe(0); + }); + + it('returns null when there is no available date between the selected date and the start of the visible range', () => { + let {result} = renderHook(() => + useCalendarState({ + locale: 'en-US', + createCalendar, + isDateUnavailable: date => date.day <= 15, + defaultFocusedValue: new CalendarDate(2026, 4, 20) + }) + ); + + // Every day from the start of the visible month through day 15 is unavailable. + act(() => { + result.current.selectDate(new CalendarDate(2026, 4, 15)); + }); + + expect(result.current.value).toBeNull(); + }); + }); +}); From fea48c38bf38fa0666346467c556ece3b2678f5a Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Wed, 12 Aug 2026 20:39:37 +0000 Subject: [PATCH 03/10] fix: commit ColorField value on Enter (#10450) --- .../test/ColorField.test.js | 38 ++++++++++++++++ .../react-aria/src/color/useColorField.ts | 45 ++++++++++++++++--- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/react-aria-components/test/ColorField.test.js b/packages/react-aria-components/test/ColorField.test.js index 99ea90fbe13..bdd4b2e6895 100644 --- a/packages/react-aria-components/test/ColorField.test.js +++ b/packages/react-aria-components/test/ColorField.test.js @@ -130,6 +130,44 @@ describe('ColorField', () => { expect(outerEl[0]).toHaveClass('react-aria-ColorField'); }); + it('should commit the typed value on Enter', async () => { + let onChange = jest.fn(); + let {getByRole} = render(); + let input = getByRole('textbox'); + + await user.tab(); + await user.clear(input); + await user.keyboard('#0000ff'); + expect(onChange).not.toHaveBeenCalled(); + + await user.keyboard('{Enter}'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(parseColor('#0000ff')); + expect(input).toHaveValue('#0000FF'); + }); + + it('should restore the previous value on Enter if the typed value cannot be parsed', async () => { + let onChange = jest.fn(); + let {getByRole} = render(); + let input = getByRole('textbox'); + + await user.tab(); + await user.clear(input); + await user.keyboard('ab'); + await user.keyboard('{Enter}'); + expect(onChange).not.toHaveBeenCalled(); + expect(input).toHaveValue('#FF0000'); + }); + + it('should call a user onKeyDown handler exactly once per key press', async () => { + let onKeyDown = jest.fn(); + render(); + + await user.tab(); + await user.keyboard('a'); + expect(onKeyDown).toHaveBeenCalledTimes(1); + }); + it('supports validation errors', async () => { let {getByRole, getByTestId} = render(
diff --git a/packages/react-aria/src/color/useColorField.ts b/packages/react-aria/src/color/useColorField.ts index 5fb408349a6..d5d5c0170ec 100644 --- a/packages/react-aria/src/color/useColorField.ts +++ b/packages/react-aria/src/color/useColorField.ts @@ -19,6 +19,7 @@ import { ValidationResult } from '@react-types/shared'; import {ColorFieldProps, ColorFieldState} from 'react-stately/useColorFieldState'; +import {flushSync} from 'react-dom'; import {InputHTMLAttributes, LabelHTMLAttributes, RefObject, useCallback, useState} from 'react'; import {mergeProps} from '../utils/mergeProps'; import {privateValidationStateProp} from 'react-stately/private/form/useFormValidationState'; @@ -26,6 +27,7 @@ import {useFocusWithin} from '../interactions/useFocusWithin'; import {useFormattedTextField} from '../textfield/useFormattedTextField'; import {useFormReset} from '../utils/useFormReset'; import {useId} from '../utils/useId'; +import {useKeyboard} from '../interactions/useKeyboard'; import {useScrollWheel} from '../interactions/useScrollWheel'; import {useSpinButton} from '../spinbutton/useSpinButton'; @@ -70,10 +72,26 @@ export function useColorField( state: ColorFieldState, ref: RefObject ): ColorFieldAria { - let {isDisabled, isReadOnly, isRequired, isWheelDisabled, validationBehavior = 'aria'} = props; + let { + isDisabled, + isReadOnly, + isRequired, + isWheelDisabled, + validationBehavior = 'aria', + onKeyDown, + onKeyUp + } = props; - let {colorValue, inputValue, increment, decrement, incrementToMax, decrementToMin, commit} = - state; + let { + colorValue, + inputValue, + increment, + decrement, + incrementToMax, + decrementToMin, + commit, + commitValidation + } = state; let inputId = useId(); let {spinButtonProps} = useSpinButton({ @@ -110,6 +128,21 @@ export function useColorField( let scrollingDisabled = isWheelDisabled || isDisabled || isReadOnly || !focusWithin; useScrollWheel({onScroll: onWheel, isDisabled: scrollingDisabled}, ref); + let {keyboardProps} = useKeyboard({ + isDisabled: isDisabled || isReadOnly, + shortcuts: { + Enter: () => { + flushSync(() => { + commit(); + }); + commitValidation(); + return {shouldPreventDefault: false}; + } + }, + onKeyDown, + onKeyUp + }); + let onChange = value => { if (state.validate(value)) { state.setInputValue(value); @@ -128,7 +161,9 @@ export function useColorField( [privateValidationStateProp]: state, type: 'text', autoComplete: 'off', - onChange + onChange, + onKeyDown: undefined, + onKeyUp: undefined }, state, ref @@ -136,7 +171,7 @@ export function useColorField( useFormReset(ref, state.defaultColorValue, state.setColorValue); - inputProps = mergeProps(inputProps, spinButtonProps, focusWithinProps, { + inputProps = mergeProps(keyboardProps, inputProps, spinButtonProps, focusWithinProps, { role: 'textbox', 'aria-valuemax': null, 'aria-valuemin': null, From a5782234af729062cf6cc6056cdecabbb99c83e1 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 12 Aug 2026 22:36:11 +0000 Subject: [PATCH 04/10] chore: SideNav in test apps (#10447) * chore: SideNav in test apps * turn on verdaccio * Revert "turn on verdaccio" This reverts commit 4973fd56dd784dda7214b0f65574004cf27f4e61. --- examples/s2-next-macros/src/app/page.tsx | 91 ++++++++++++++++++ examples/s2-parcel-example/src/App.js | 83 +++++++++++++++++ examples/s2-vite-project/src/App.tsx | 91 ++++++++++++++++++ examples/s2-webpack-5-example/src/App.js | 83 +++++++++++++++++ .../src/App.tsx | 92 +++++++++++++++++++ 5 files changed, 440 insertions(+) diff --git a/examples/s2-next-macros/src/app/page.tsx b/examples/s2-next-macros/src/app/page.tsx index e1cef34b79a..16125aac36e 100644 --- a/examples/s2-next-macros/src/app/page.tsx +++ b/examples/s2-next-macros/src/app/page.tsx @@ -38,6 +38,10 @@ import { PickerItem, Provider, Row, + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, SubmenuTrigger, TableBody, TableHeader, @@ -53,9 +57,12 @@ import { useListData, useTreeData } from '@react-spectrum/s2'; +import Delete from '@react-spectrum/s2/icons/Delete'; import Edit from '@react-spectrum/s2/icons/Edit'; +import File from '@react-spectrum/s2/icons/File'; import FileTxt from '@react-spectrum/s2/icons/FileText'; import Folder from '@react-spectrum/s2/icons/Folder'; +import {RouterProvider} from 'react-aria-components'; import Section from './components/Section'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CardViewExample} from './components/CardViewExample'; @@ -222,6 +229,89 @@ function ReorderableTreeView() { ); } +interface SideNavItemData { + id: number; + title: string; + type: 'directory' | 'file'; + href?: string; + children?: SideNavItemData[]; +} + +let sideNavItems: SideNavItemData[] = [ + { + id: 1, + title: 'Documents', + type: 'directory', + children: [ + { + id: 2, + title: 'Project', + type: 'directory', + children: [ + {id: 3, title: 'Notes', type: 'file', href: '/notes'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ] + } + ] + }, + { + id: 5, + title: 'Photos', + type: 'directory', + children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ] + } +]; + +// Wrap SideNav in a RouterProvider whose navigate only updates local state so +// clicking a link selects the item without actually navigating the page. +function SideNavExample() { + let [selectedRoute, setSelectedRoute] = useState('/notes'); + return ( + setSelectedRoute(href)}> + + {function renderItem(item: SideNavItemData) { + return ( + + + {item.href ? ( + + {item.type === 'directory' ? : } + {item.title} + + ) : ( + <> + {item.type === 'directory' ? : } + {item.title} + + )} + + + + Edit + + + + Delete + + + + {item.children && {renderItem}} + + ); + }} + + + ); +} + function App() { let [isLazyLoaded, setLazyLoaded] = useState(false); let [cardViewState, setCardViewState] = useState({ @@ -355,6 +445,7 @@ function App() { + {!isLazyLoaded && ( diff --git a/examples/s2-parcel-example/src/App.js b/examples/s2-parcel-example/src/App.js index 67fbe0f7414..2602a87a3a1 100644 --- a/examples/s2-parcel-example/src/App.js +++ b/examples/s2-parcel-example/src/App.js @@ -36,6 +36,10 @@ import { PickerItem, Provider, Row, + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, SubmenuTrigger, TableBody, TableHeader, @@ -51,9 +55,12 @@ import { useListData, useTreeData } from '@react-spectrum/s2'; +import Delete from '@react-spectrum/s2/icons/Delete'; import Edit from '@react-spectrum/s2/icons/Edit'; +import File from '@react-spectrum/s2/icons/File'; import FileTxt from '@react-spectrum/s2/icons/FileText'; import Folder from '@react-spectrum/s2/icons/Folder'; +import {RouterProvider} from 'react-aria-components'; import Section from './components/Section'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CardViewExample} from './components/CardViewExample'; @@ -218,6 +225,81 @@ function ReorderableTreeView() { ); } +let sideNavItems = [ + { + id: 1, + title: 'Documents', + type: 'directory', + children: [ + { + id: 2, + title: 'Project', + type: 'directory', + children: [ + {id: 3, title: 'Notes', type: 'file', href: '/notes'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ] + } + ] + }, + { + id: 5, + title: 'Photos', + type: 'directory', + children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ] + } +]; + +// Wrap SideNav in a RouterProvider whose navigate only updates local state so +// clicking a link selects the item without actually navigating the page. +function SideNavExample() { + let [selectedRoute, setSelectedRoute] = useState('/notes'); + return ( + setSelectedRoute(href)}> + + {function renderItem(item) { + return ( + + + {item.href ? ( + + {item.type === 'directory' ? : } + {item.title} + + ) : ( + <> + {item.type === 'directory' ? : } + {item.title} + + )} + + + + Edit + + + + Delete + + + + {item.children && {renderItem}} + + ); + }} + + + ); +} + function App() { let [isLazyLoaded, setLazyLoaded] = useState(false); let [cardViewState, setCardViewState] = useState({ @@ -349,6 +431,7 @@ function App() { + {!isLazyLoaded && ( diff --git a/examples/s2-vite-project/src/App.tsx b/examples/s2-vite-project/src/App.tsx index 9c317800944..a2418c4ea23 100644 --- a/examples/s2-vite-project/src/App.tsx +++ b/examples/s2-vite-project/src/App.tsx @@ -34,6 +34,10 @@ import { PickerItem, Provider, Row, + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, SubmenuTrigger, TableBody, TableHeader, @@ -48,9 +52,12 @@ import { useListData, useTreeData } from '@react-spectrum/s2'; +import Delete from '@react-spectrum/s2/icons/Delete'; import Edit from '@react-spectrum/s2/icons/Edit'; +import File from '@react-spectrum/s2/icons/File'; import FileTxt from '@react-spectrum/s2/icons/FileText'; import Folder from '@react-spectrum/s2/icons/Folder'; +import {RouterProvider} from 'react-aria-components'; import Section from './components/Section'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CardViewExample} from './components/CardViewExample'; @@ -217,6 +224,89 @@ function ReorderableTreeView() { ); } +interface SideNavItemData { + id: number; + title: string; + type: 'directory' | 'file'; + href?: string; + children?: SideNavItemData[]; +} + +let sideNavItems: SideNavItemData[] = [ + { + id: 1, + title: 'Documents', + type: 'directory', + children: [ + { + id: 2, + title: 'Project', + type: 'directory', + children: [ + {id: 3, title: 'Notes', type: 'file', href: '/notes'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ] + } + ] + }, + { + id: 5, + title: 'Photos', + type: 'directory', + children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ] + } +]; + +// Wrap SideNav in a RouterProvider whose navigate only updates local state so +// clicking a link selects the item without actually navigating the page. +function SideNavExample() { + let [selectedRoute, setSelectedRoute] = useState('/notes'); + return ( + setSelectedRoute(href)}> + + {function renderItem(item: SideNavItemData) { + return ( + + + {item.href ? ( + + {item.type === 'directory' ? : } + {item.title} + + ) : ( + <> + {item.type === 'directory' ? : } + {item.title} + + )} + + + + Edit + + + + Delete + + + + {item.children && {renderItem}} + + ); + }} + + + ); +} + function App() { let [isLazyLoaded, setLazyLoaded] = useState(false); let [cardViewState, setCardViewState] = useState({ @@ -344,6 +434,7 @@ function App() { + {!isLazyLoaded && ( diff --git a/examples/s2-webpack-5-example/src/App.js b/examples/s2-webpack-5-example/src/App.js index 3cfecfc5669..5027d8c8a71 100644 --- a/examples/s2-webpack-5-example/src/App.js +++ b/examples/s2-webpack-5-example/src/App.js @@ -36,6 +36,10 @@ import { PickerItem, Provider, Row, + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, SubmenuTrigger, TableBody, TableHeader, @@ -51,9 +55,12 @@ import { useListData, useTreeData } from '@react-spectrum/s2'; +import Delete from '@react-spectrum/s2/icons/Delete'; import Edit from '@react-spectrum/s2/icons/Edit'; +import File from '@react-spectrum/s2/icons/File'; import FileTxt from '@react-spectrum/s2/icons/FileText'; import Folder from '@react-spectrum/s2/icons/Folder'; +import {RouterProvider} from 'react-aria-components'; import Section from './components/Section'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CardViewExample} from './components/CardViewExample'; @@ -218,6 +225,81 @@ function ReorderableTreeView() { ); } +let sideNavItems = [ + { + id: 1, + title: 'Documents', + type: 'directory', + children: [ + { + id: 2, + title: 'Project', + type: 'directory', + children: [ + {id: 3, title: 'Notes', type: 'file', href: '/notes'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ] + } + ] + }, + { + id: 5, + title: 'Photos', + type: 'directory', + children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ] + } +]; + +// Wrap SideNav in a RouterProvider whose navigate only updates local state so +// clicking a link selects the item without actually navigating the page. +function SideNavExample() { + let [selectedRoute, setSelectedRoute] = useState('/notes'); + return ( + setSelectedRoute(href)}> + + {function renderItem(item) { + return ( + + + {item.href ? ( + + {item.type === 'directory' ? : } + {item.title} + + ) : ( + <> + {item.type === 'directory' ? : } + {item.title} + + )} + + + + Edit + + + + Delete + + + + {item.children && {renderItem}} + + ); + }} + + + ); +} + function App() { let [isLazyLoaded, setLazyLoaded] = useState(false); let [cardViewState, setCardViewState] = useState({ @@ -349,6 +431,7 @@ function App() { + {!isLazyLoaded && ( diff --git a/examples/s2-webpack-5-typescript-example/src/App.tsx b/examples/s2-webpack-5-typescript-example/src/App.tsx index f06a7314668..7f2944443c9 100644 --- a/examples/s2-webpack-5-typescript-example/src/App.tsx +++ b/examples/s2-webpack-5-typescript-example/src/App.tsx @@ -19,6 +19,7 @@ import { Button, ButtonGroup, Cell, + Collection, Column, Divider, Heading, @@ -31,6 +32,10 @@ import { PickerItem, Provider, Row, + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, SubmenuTrigger, TableBody, TableHeader, @@ -44,9 +49,12 @@ import { } from '@react-spectrum/s2'; import {CardViewExample} from './components/CardViewExample'; import {CollectionCardsExample} from './components/CollectionCardsExample'; +import Delete from '@react-spectrum/s2/icons/Delete'; import Edit from '@react-spectrum/s2/icons/Edit'; +import File from '@react-spectrum/s2/icons/File'; import FileTxt from '@react-spectrum/s2/icons/FileText'; import Folder from '@react-spectrum/s2/icons/Folder'; +import {RouterProvider} from 'react-aria-components'; import {LoadingState} from '@react-types/shared'; import React, {useState} from 'react'; import Section from './components/Section'; @@ -54,6 +62,89 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; const Lazy = React.lazy(() => import('./Lazy')); +interface SideNavItemData { + id: number; + title: string; + type: 'directory' | 'file'; + href?: string; + children?: SideNavItemData[]; +} + +let sideNavItems: SideNavItemData[] = [ + { + id: 1, + title: 'Documents', + type: 'directory', + children: [ + { + id: 2, + title: 'Project', + type: 'directory', + children: [ + {id: 3, title: 'Notes', type: 'file', href: '/notes'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ] + } + ] + }, + { + id: 5, + title: 'Photos', + type: 'directory', + children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ] + } +]; + +// Wrap SideNav in a RouterProvider whose navigate only updates local state so +// clicking a link selects the item without actually navigating the page. +function SideNavExample() { + let [selectedRoute, setSelectedRoute] = useState('/notes'); + return ( + setSelectedRoute(href)}> + + {function renderItem(item: SideNavItemData) { + return ( + + + {item.href ? ( + + {item.type === 'directory' ? : } + {item.title} + + ) : ( + <> + {item.type === 'directory' ? : } + {item.title} + + )} + + + + Edit + + + + Delete + + + + {item.children && {renderItem}} + + ); + }} + + + ); +} + function App() { let [isLazyLoaded, setLazyLoaded] = useState(false); let [cardViewState, setCardViewState] = useState<{ @@ -263,6 +354,7 @@ function App() { + {!isLazyLoaded && ( From d841ef59c149aaa26247e06ce97c17cc7f6e43ee Mon Sep 17 00:00:00 2001 From: Tim Gesemann Date: Wed, 12 Aug 2026 22:51:36 +0000 Subject: [PATCH 05/10] fix: relax inputRef type on Checkbox, Switch, and Radio to accept callback refs (#10451) CheckboxProps, SwitchProps, and RadioProps (and their Field variants) typed inputRef as RefObject, so a ref callback couldn't be passed even though mergeRefs already supports one. Widen inputRef to Ref on all six, widen useCheckboxAria's internal parameter to match, and memoize Switch's ref merge like Checkbox/Radio already do so a callback ref only tears down on unmount instead of every render. Closes #10319 --- packages/react-aria-components/src/Checkbox.tsx | 8 ++++---- .../react-aria-components/src/RadioGroup.tsx | 6 +++--- packages/react-aria-components/src/Switch.tsx | 16 +++++++++++----- .../react-aria-components/test/Checkbox.test.js | 9 +++++++++ .../test/RadioGroup.test.js | 17 +++++++++++++++++ .../react-aria-components/test/Switch.test.js | 9 +++++++++ 6 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index 45404c4eba8..fd2298af0d5 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -39,7 +39,7 @@ import {HoverEvents} from '@react-types/shared'; import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; -import React, {createContext, ForwardedRef, forwardRef, useContext, useMemo} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {TextContext} from './Text'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; @@ -89,7 +89,7 @@ export interface CheckboxProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface CheckboxFieldProps @@ -109,7 +109,7 @@ export interface CheckboxFieldProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface CheckboxButtonProps @@ -429,7 +429,7 @@ export const CheckboxField = /*#__PURE__*/ (forwardRef as forwardRefType)(functi function useCheckboxAria( props: CheckboxProps | CheckboxFieldProps, - userProvidedInputRef: RefObject | null + userProvidedInputRef: Ref | null ): [CheckboxAria, RefObject] { let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index 4f9f59dfe8f..caf52c62906 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -40,7 +40,7 @@ import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import {RadioGroupState, useRadioGroupState} from 'react-stately/useRadioGroupState'; -import React, {createContext, ForwardedRef, forwardRef, useContext, useMemo} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TextContext} from './Text'; @@ -89,7 +89,7 @@ export interface RadioProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface RadioFieldProps @@ -108,7 +108,7 @@ export interface RadioFieldProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface RadioButtonProps diff --git a/packages/react-aria-components/src/Switch.tsx b/packages/react-aria-components/src/Switch.tsx index b81cd69c0b3..067458b5858 100644 --- a/packages/react-aria-components/src/Switch.tsx +++ b/packages/react-aria-components/src/Switch.tsx @@ -32,7 +32,7 @@ import {forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/share import {HoverEvents} from '@react-types/shared'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; -import React, {createContext, ForwardedRef, forwardRef, useContext} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {TextContext} from './Text'; import {ToggleState, useToggleState} from 'react-stately/useToggleState'; import {useFocusRing} from 'react-aria/useFocusRing'; @@ -65,7 +65,7 @@ export interface SwitchProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface SwitchFieldProps @@ -85,7 +85,7 @@ export interface SwitchFieldProps /** * A ref for the HTML input element. */ - inputRef?: RefObject; + inputRef?: Ref; } export interface SwitchButtonProps @@ -225,7 +225,10 @@ export const Switch = /*#__PURE__*/ (forwardRef as forwardRefType)(function Swit let {inputRef: userProvidedInputRef = null, ...otherProps} = props; [props, ref] = useContextProps(otherProps, ref, SwitchContext); let inputRef = useObjectRef( - mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null) + useMemo( + () => mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null), + [userProvidedInputRef, props.inputRef] + ) ); let state = useToggleState(props); let aria = useSwitch( @@ -276,7 +279,10 @@ export const SwitchField = /*#__PURE__*/ (forwardRef as forwardRefType)(function let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; let inputRef = useObjectRef( - mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null) + useMemo( + () => mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null), + [userProvidedInputRef, props.inputRef] + ) ); let state = useToggleState(props); let aria = useSwitch( diff --git a/packages/react-aria-components/test/Checkbox.test.js b/packages/react-aria-components/test/Checkbox.test.js index f87ce806420..7a44370b3e5 100644 --- a/packages/react-aria-components/test/Checkbox.test.js +++ b/packages/react-aria-components/test/Checkbox.test.js @@ -423,6 +423,15 @@ describe.each(['Checkbox', 'CheckboxField'])('%s', comp => { expect(inputRef.current).toBe(getByRole('checkbox')); }); + it('should support callback ref', () => { + let cleanup = jest.fn(); + let onRef = jest.fn(() => cleanup); + let {getByRole, unmount} = render(Test); + expect(onRef).toHaveBeenCalledWith(getByRole('checkbox')); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it('should support and merge input ref on context', () => { let inputRef = React.createRef(); let contextInputRef = React.createRef(); diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index 596497a88a3..2a95a682654 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -755,6 +755,23 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { expect(inputRef.current).toBe(radio); }); + it('should support callback ref', () => { + let cleanup = jest.fn(); + let onRef = jest.fn(() => cleanup); + let {getByRole, unmount} = render( + + + + A + + + ); + let radio = getByRole('radio'); + expect(onRef).toHaveBeenCalledWith(radio); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it('should support and merge input ref on context', () => { let inputRef = React.createRef(); let contextInputRef = React.createRef(); diff --git a/packages/react-aria-components/test/Switch.test.js b/packages/react-aria-components/test/Switch.test.js index 5433f99d85d..9f7d6d7f1ae 100644 --- a/packages/react-aria-components/test/Switch.test.js +++ b/packages/react-aria-components/test/Switch.test.js @@ -342,6 +342,15 @@ describe.each(['Switch', 'SwitchField'])('%s', comp => { expect(inputRef.current).toBe(getByRole('switch')); }); + it('should support callback ref', () => { + let cleanup = jest.fn(); + let onRef = jest.fn(() => cleanup); + let {getByRole, unmount} = render(Test); + expect(onRef).toHaveBeenCalledWith(getByRole('switch')); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it('should support and merge input ref on context', () => { let inputRef = React.createRef(); let contextInputRef = React.createRef(); From 2276b49115ffd2266c4dfa46e1001aeaf560ec17 Mon Sep 17 00:00:00 2001 From: Jason Colapietro <55137770+JasonColapietro@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:51:56 +0000 Subject: [PATCH 06/10] feat: add useShowFocusIndicator hook (#10426) * feat: add useShowFocusIndicator hook * test: simulate programmatic validation focus * test: wrap validation focus in act * test: simulate the programmatic focus directly, drop library framing * simplify test * test: correct focus indicator test description --------- Co-authored-by: Rob Snow --- packages/react-aria/exports/index.ts | 1 + .../exports/useShowFocusIndicator.ts | 13 +++++++ .../src/interactions/useFocusVisible.ts | 9 ++++- .../test/interactions/useFocusVisible.test.js | 37 ++++++++++++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 packages/react-aria/exports/useShowFocusIndicator.ts diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts index 84ae4850e3a..2e0d996c660 100644 --- a/packages/react-aria/exports/index.ts +++ b/packages/react-aria/exports/index.ts @@ -77,6 +77,7 @@ export {useNumberFormatter} from '../src/i18n/useNumberFormatter'; export {useListFormatter} from '../src/i18n/useListFormatter'; export {useFocus} from '../src/interactions/useFocus'; export {useFocusVisible} from '../src/interactions/useFocusVisible'; +export {useShowFocusIndicator} from '../src/interactions/useFocusVisible'; export {useFocusWithin} from '../src/interactions/useFocusWithin'; export {useHover} from '../src/interactions/useHover'; export {useInteractOutside} from '../src/interactions/useInteractOutside'; diff --git a/packages/react-aria/exports/useShowFocusIndicator.ts b/packages/react-aria/exports/useShowFocusIndicator.ts new file mode 100644 index 00000000000..50e431f11a8 --- /dev/null +++ b/packages/react-aria/exports/useShowFocusIndicator.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export {useShowFocusIndicator} from '../src/interactions/useFocusVisible'; diff --git a/packages/react-aria/src/interactions/useFocusVisible.ts b/packages/react-aria/src/interactions/useFocusVisible.ts index 9e992f5334f..72b1d22fd25 100644 --- a/packages/react-aria/src/interactions/useFocusVisible.ts +++ b/packages/react-aria/src/interactions/useFocusVisible.ts @@ -23,7 +23,7 @@ import {isMac} from '../utils/platform'; import {isVirtualClick} from '../utils/isVirtualEvent'; import {openLink} from '../utils/openLink'; import {PointerType} from '@react-types/shared'; -import {useEffect, useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import {useIsSSR} from '../ssr/SSRProvider'; export type Modality = 'keyboard' | 'pointer' | 'virtual'; @@ -303,6 +303,13 @@ export function setInteractionModality(modality: Modality): void { triggerChangeHandlers(modality, null); } +/** + * Returns a callback that makes the focus indicator visible. + */ +export function useShowFocusIndicator(): () => void { + return useCallback(() => setInteractionModality('keyboard'), []); +} + /** @private */ export function getPointerType(): PointerType { return currentPointerType; diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index d469996ee35..72c798438d0 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -21,7 +21,8 @@ import { import { addWindowFocusTracking, useFocusVisible, - useFocusVisibleListener + useFocusVisibleListener, + useShowFocusIndicator } from '../../src/interactions/useFocusVisible'; import {changeHandlers, hasSetupGlobalListeners} from '../../src/interactions/useFocusVisible'; import {mergeProps} from '../../src/utils/mergeProps'; @@ -375,6 +376,40 @@ describe('useFocusVisible', function () { }); }); +describe('useShowFocusIndicator', function () { + // A form library that moves focus to the first invalid field does so by calling + // element.focus() from the submit handler. Clicking submit leaves the modality on + // 'pointer', so the programmatic focus lands without a visible indicator. + function FormExample() { + let {focusProps, isFocusVisible} = useFocusRing(); + let showFocusIndicator = useShowFocusIndicator(); + let ref = React.useRef(null); + + let onSubmit = e => { + e.preventDefault(); + showFocusIndicator(); + ref.current.focus(); + }; + + return ( + + + + + ); + } + + it('shows the focus indicator on programmatic focus after a pointer interaction', async function () { + let user = userEvent.setup({delay: null, pointerMap}); + render(); + await user.click(screen.getByRole('button', {name: 'Submit'})); + + let input = screen.getByRole('textbox'); + expect(input).toHaveFocus(); + expect(input).toHaveAttribute('data-focus-visible'); + }); +}); + describe('useFocusVisibleListener', function () { it('emits on modality change (non-text input)', function () { let fnMock = jest.fn(); From 4e4357bb2300354838499ed4b20e8069b9f7436e Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 12 Aug 2026 23:09:37 +0000 Subject: [PATCH 07/10] chore: add a Claude.md file to help with contributions (#10343) * chore: add a Claude.md file to help with contributions * improving AI contribution expectations * divvy up claude.md to task specific files * Update CONTRIBUTING.md Co-authored-by: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> * review updates * code review * fix symlink --------- Co-authored-by: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> --- .github/PULL_REQUEST_TEMPLATE.md | 3 ++ AGENTS.md | 36 +++++++++++++++++++++++ CLAUDE.md | 3 ++ CONTRIBUTING.md | 46 ++++++++++++++++++++++++++++++ docs/contributing/codegen.md | 3 ++ docs/contributing/i18n-strings.md | 3 ++ docs/contributing/pull-requests.md | 11 +++++++ docs/contributing/s2-styling.md | 3 ++ docs/contributing/testing.md | 26 +++++++++++++++++ docs/contributing/tooling.md | 13 +++++++++ 10 files changed, 147 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/contributing/codegen.md create mode 100644 docs/contributing/i18n-strings.md create mode 100644 docs/contributing/pull-requests.md create mode 100644 docs/contributing/s2-styling.md create mode 100644 docs/contributing/testing.md create mode 100644 docs/contributing/tooling.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 85fa50515bf..3c0ae90bdb2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,4 @@ + Closes @@ -8,6 +9,8 @@ Closes - [ ] Filled out test instructions. - [ ] Updated documentation (if it already exists for this component). - [ ] Looked at the Accessibility Practices for this feature - [Aria Practices](https://www.w3.org/WAI/ARIA/apg/) +- [ ] I understand every change in this PR and can explain why it's there. +- [ ] If AI-assisted, I followed our [AI contribution guidance](https://github.com/adobe/react-spectrum/blob/main/CONTRIBUTING.md#ai-assisted-contributions) and pointed my assistant at [CLAUDE.md](https://github.com/adobe/react-spectrum/blob/main/CLAUDE.md). ## 📝 Test Instructions: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..2649d884eaa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# AGENTS.md + +Guidance for working in the react-spectrum monorepo. + +## Repo layout + +The repo is layered. Changes flow up from the lowest level: + +- **`@internationalized/*` and `@react-stately/*`** — the two lowest levels (i18n utilities and state management). +- **`@react-aria/*`** — behavior and accessibility hooks built on the above. +- **`react-aria-components` (RAC)** and some **React Spectrum v3 (RSP)** — component layer built on the hooks. +- **RSP S2 (`@react-spectrum/s2`)** — the Spectrum 2 design system, built on RAC, the highest level. + +## Toolchain guardrails + +This repo does **not** use the conventional JS toolchain — use these, don't swap in defaults: + +- Format with `yarn format` (oxfmt), **not** Prettier. Lint with `yarn lint` (oxlint), **not** ESLint. Type-check with `yarn check-types` (tsgo), **not** tsc. Build with `yarn build` (Parcel), **not** rollup/tsc. +- **Don't run `yarn chromatic` / `yarn chromatic:forced-colors`** — maintainers run the VRT suites. +- All commonly used commands live in the root `package.json` scripts. + +## Contributing + +- **Match the surrounding code** — follow the naming, structure, and patterns of neighboring files. +- **Commit format** — use conventional-commit prefixes (`fix:`, `feat:`, `chore:`, `docs:`) as seen in the git history. + +## Task-specific workflows + +Read the relevant file before starting that kind of work (other agents: read the file directly; Claude will surface it): + +- Writing or running tests → [`docs/contributing/testing.md`](docs/contributing/testing.md) +- Tooling details (format/lint/type-check/build, Storybook, workspaces) → [`docs/contributing/tooling.md`](docs/contributing/tooling.md) +- Styling S2 components → [`docs/contributing/s2-styling.md`](docs/contributing/s2-styling.md) +- Adding user-facing strings → [`docs/contributing/i18n-strings.md`](docs/contributing/i18n-strings.md) +- Touching generated code (icons) → [`docs/contributing/codegen.md`](docs/contributing/codegen.md) +- Comments and opening a PR → [`docs/contributing/pull-requests.md`](docs/contributing/pull-requests.md) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..ca5718bf6e8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +Read AGENTS.md for project architecture and conventions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff52e57539a..d6542dc5420 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,52 @@ Read [GitHub's pull request documentation](https://help.github.com/articles/abou Lastly, please follow the pull request template when submitting a pull request! + +## AI-assisted contributions +Setting expectations: the AI doesn't contribute to React Spectrum, you do. The AI is a tool, but you are still the author, and you own every line, every decision, and every explanation. + +If you use an AI assistant, point it at our [AGENTS.md](AGENTS.md), which captures the repo conventions we expect it to follow. The detailed conventions are split across [`docs/contributing/`](docs/contributing/) — see testing, tooling, s2-styling, i18n-strings, codegen, and pull-requests. + +### Aligning on a solution + +Open an issue or discussion, or at minimum, describe the problem and intended solution in the PR description. Otherwise, we have to reverse-engineer both the problem and the intended solution before we can even begin reviewing. An issue with: "here's what I'm seeing, here's what I plan to do, does that sound right?" really helps. + +### Give us the intent, not just the fix + +Tell us what you want and why, separately from how you did it. Then if we need to make a change to the PR, we can be confident that we're satisfying the goal you had. This framing helps when prompting the AI as well. + +### Tell us what you tested + +* mouse / touch / keyboard / screen reader +* LTR / RTL +* light / dark / high-contrast +* disabled / loading / error / empty +* narrow / wide / truncated / very long / wrapping text +* component sizes and zoom levels + +Even if you haven't tested all of these, it's hugely helpful to us to know where to focus efforts. + +### Beware false confidence + +One of the biggest issues we face with the rise of AI contributions is the wrong root cause but with a lot of details asserting why it is, in fact, the issue. Press the AI, and ask both it and yourself "is this the root cause, or a symptom?" and "what else could cause this?" before committing to a solution. + +### Keep a human in the loop of the conversation + +When you iterate between reviews, be sure you can say what changed and why, one sentence is enough. A PR that transforms completely between every review without explanation is exhausting to follow and makes us feel like we're arguing with a machine instead of collaborating with a person. + + +#### Requirements of front end code + +These can be useful constraints or reminders as AI is not inherently good at these things. For the toolchain that enforces some of this, see [`docs/contributing/tooling.md`](docs/contributing/tooling.md). + +* **Small** — everything you add ships across the network to the client, so more code means slower load times. +* **Fast** — must run well on constrained CPU and memory, not just the latest MacBook Pro. Many users are on years-old, low-end Android devices. +* **Mindful of the shared environment** — keep the global namespace clean, don't hog resources or throw uncaught errors, avoid CSS that leaks across boundaries, and keep ids unique. +* **Stable** — RSP and Quarry are libraries with many downstream dependents who upgrade on their own schedule, so avoid breaking them. +* **Accessible** — accessibility is still a relatively new web requirement, so strong examples are scarce and bad ones are common. Use other examples in the repo or the APG examples first. +* **Cross-environment** — works across browsers, assistive technologies, and devices. + + ### Contributor License Agreement All third-party contributions to this project must be accompanied by a signed contributor license agreement. This gives Adobe permission to redistribute your contributions as part of the project. [Sign our CLA](https://opensource.adobe.com/cla.html). You only need to submit an Adobe CLA one time, so if you have submitted one previously, you are good to go! diff --git a/docs/contributing/codegen.md b/docs/contributing/codegen.md new file mode 100644 index 00000000000..4b140d3230e --- /dev/null +++ b/docs/contributing/codegen.md @@ -0,0 +1,3 @@ +# Generated code + +@adobe/react-spectrum v3 icon components are generated (`yarn build:icons`) and @react-spectrum/s2 icons are handled through a parcel transformer, not hand-written, and `postinstall` runs `patch-package`, so run install on a fresh clone. diff --git a/docs/contributing/i18n-strings.md b/docs/contributing/i18n-strings.md new file mode 100644 index 00000000000..bea91a05b9a --- /dev/null +++ b/docs/contributing/i18n-strings.md @@ -0,0 +1,3 @@ +# User-facing strings (i18n) + +Add the key to the package's `intl/en-US.json` (ICU MessageFormat) and read it via the localized string hook. Never hardcode UI text, and don't hand-edit the other locale files (translators own those). diff --git a/docs/contributing/pull-requests.md b/docs/contributing/pull-requests.md new file mode 100644 index 00000000000..e2f01a9a622 --- /dev/null +++ b/docs/contributing/pull-requests.md @@ -0,0 +1,11 @@ +# Pull requests + +## Commenting + +Comments while developing are fine. Before presenting code for review, trim verbose comments so the diff reads cleanly. When a genuinely complex section still warrants a comment, prefer a higher-level explanation of the whole section over annotating individual lines. Specific line comments are warranted when it's addressing a browser bug or the code deviates from other patterns. + +## Opening a PR + +Start from `.github/PULL_REQUEST_TEMPLATE.md` (e.g. `gh pr create --body-file .github/PULL_REQUEST_TEMPLATE.md`) rather than writing a body from scratch: fill in every section, complete the checklist honestly, and disclose AI use. Above the checklist, add a holistic summary of how the changes work and why this approach was chosen — give the intent separately from the implementation. + +See also `CONTRIBUTING.md` (AI-assisted contributions). diff --git a/docs/contributing/s2-styling.md b/docs/contributing/s2-styling.md new file mode 100644 index 00000000000..0132a742eb1 --- /dev/null +++ b/docs/contributing/s2-styling.md @@ -0,0 +1,3 @@ +# S2 styling + +Style with the `style` [macro](https://react-spectrum.adobe.com/styling) (`import {style} from '../style' with {type: 'macro'};` — the `with {type: 'macro'}` attribute is required). Pass typed style objects to it; don't write CSS files or hand-rolled className strings for S2. diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md new file mode 100644 index 00000000000..c5c16b7cbb0 --- /dev/null +++ b/docs/contributing/testing.md @@ -0,0 +1,26 @@ +# Testing + +Test suites are split by type: + +- **Jest tests** — `yarn test` +- **SSR tests** — `yarn test:ssr` +- **Browser tests** — `yarn test:browser` +- **Visual regression tests (VRT)** — `yarn chromatic` +- **High-contrast-mode VRT** — `yarn chromatic:forced-colors` + +Maintainers run the Chromatic VRT suites themselves — don't run `yarn chromatic` / `yarn chromatic:forced-colors`. You can still start the VRT Storybooks locally to verify visual state: `yarn start:chromatic` and `yarn start:chromatic-fc`. + +Tests are **not** co-located with source — each package keeps them in a sibling `test/` directory. The file suffix routes the test to a runner: `*.ssr.test.*` → `yarn test:ssr`, `*.browser.test.*` → `yarn test:browser`, plain `*.test.*` → `yarn test` (Jest). Shared test helpers live in `@react-aria/test-utils` / `@react-spectrum/test-utils` (the `User` event abstraction and per-component testers). + +## Writing tests + +- **Run the full suite before committing.** Do not write PR descriptions that list a subset of specific passing tests — run `yarn test`, `yarn test:ssr` and (when relevant) `yarn test:browser` — do not run the Chromatic VRT suites (see above). +- **Run lint and formatting before committing** (`yarn lint`, `yarn format`). +- **Test at the right level.** For any change at the RAC level or below (including hooks), write the test at the RAC level ideally. If the change lives at a higher level, test at that level. +- **Move to browser tests when needed.** If a test requires mocking specific browser behavior, consider moving it to the browser run (`yarn test:browser`). +- **Cover the reported issue.** When fixing a reported issue, add a test that reproduces the specific example given in the issue. +- **Check whether the test already exists.** Find a home for it near other similar tests. +- **Check code coverage** to help decide whether a new test adds value — this is subjective. +- **In unit tests, prefer** fake timers, our test utils, and user event. Aside from those, prefer not mocking other modules, instead, move the test to a higher level. +- **Combine tests** that share the same setup before an assertion. +- **Ground test titles in the goal**, not the implementation — double-check they are accurate. diff --git a/docs/contributing/tooling.md b/docs/contributing/tooling.md new file mode 100644 index 00000000000..d9f84b8c214 --- /dev/null +++ b/docs/contributing/tooling.md @@ -0,0 +1,13 @@ +# Tooling + +This repo does **not** use the conventional JS toolchain — reach for these, and don't hand-format code or swap in defaults: + +- **Format** — `oxfmt` (`yarn format`), not Prettier. The style is opinionated (single quotes, no bracket spacing → `{foo}`, no trailing commas). Always run the tool rather than formatting by hand. +- **Lint** — `oxlint` (`oxlint packages --fix`) plus repo-local rules, not ESLint. `yarn lint` bundles format-check, type-check, `oxlint`, and Yarn `constraints` (`yarn constraints --fix`) (which enforce cross-package dependency versions). +- **Type-check** — `tsgo` (`yarn check-types`), the native TypeScript compiler — not `tsc`. +- **Build** — Parcel driven by `make` (`yarn build`), not plain `tsc`/rollup. +- **Yarn 4 workspaces** monorepo; use `yarn workspaces foreach` for cross-package operations. + +## Storybook + +Storybook is the main way to develop and view components: `yarn start` (v3/RAC) and `yarn start:s2` (S2). From d72e7c4c7ae2b77c837e26349d5ed4aad23b8f2d Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 12 Aug 2026 23:38:24 +0000 Subject: [PATCH 08/10] feat: Add support for placeholder tokens in PromptField (#10438) * Store the full selected range in TokenFieldValue * Improve token announcement and selection behavior * Add support for placeholder tokens in PromptField * fix lint * Fix showing autocomplete when clicking placeholder token * Only expand range around token when not collapsed * Collapse selection on blur but don't lose the position fixes inserting objects via plus menu * fix firefox and safari styling issues * useKeyboard * show menu on all tokens, not just placeholders * fix popover positioning when token is the first element * Add PromptField browser tests * back compat * raise resource class for browser tests * lint * try keyboard? * run tests in jsdom instead of browser * only set selection when it has changed * tab through all tokens --- .circleci/config.yml | 1 + packages/@react-spectrum/ai/exports/index.ts | 1 + .../@react-spectrum/ai/src/PromptField.tsx | 386 ++++++++++++---- .../ai/stories/Chat.stories.tsx | 2 +- .../ai/stories/PromptField.stories.tsx | 233 +++++++--- .../ai/test/PromptField.test.tsx | 421 ++++++++++++++++++ .../ai/test/utils/promptFieldTestUtils.tsx | 362 +++++++++++++++ .../test/TokenField.browser.test.tsx | 18 + .../test/utils/tokenFieldBrowserUtils.tsx | 2 +- .../react-aria/src/tokenfield/useToken.ts | 6 +- .../src/tokenfield/useTokenField.ts | 189 +++++--- .../exports/useTokenFieldState.ts | 1 + .../src/tokenfield/TokenFieldValue.ts | 88 +++- vitest.browser.config.ts | 7 +- 14 files changed, 1492 insertions(+), 225 deletions(-) create mode 100644 packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx diff --git a/.circleci/config.yml b/.circleci/config.yml index 9c118a4cff4..4143cad465b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -414,6 +414,7 @@ jobs: test-browser: docker: - image: mcr.microsoft.com/playwright:v1.60.0-noble + resource_class: 2xlarge.gen2 working_directory: /home/circleci/react-spectrum steps: - restore_cache: diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 873d5def2d5..7fd5afd63de 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -47,6 +47,7 @@ export type { PromptFieldAttachmentListProps, PromptTokenFieldPopoverProps, PromptFieldToolbarProps, + PromptFieldTokenValue, InsertMenuItemProps, PromptFieldVoiceButtonProps, InsertTokenMenuItemProps, diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 44cad4ad784..83958e2f52a 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -14,22 +14,16 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton'; import Attach from '@react-spectrum/s2/icons/Attach'; import {Attachment, AttachmentList, AttachmentListProps} from './AttachmentList'; import {Autocomplete} from 'react-aria-components/Autocomplete'; -import { - baseColor, - color, - css, - iconStyle, - style, - StyleString -} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Button} from '@react-spectrum/s2/Button'; import {Cell} from './loader/data'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; +import {color, css, space, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { createContext, createRef, forwardRef, use, + useCallback, useContext, useDeferredValue, useEffect, @@ -38,6 +32,7 @@ import { useState } from 'react'; import {FocusableRef} from '@react-types/shared'; +import {getInteractionModality} from 'react-aria/private/interactions/useFocusVisible'; import {IconContext} from '@react-spectrum/s2'; import {Image, Text} from '@react-spectrum/s2/Card'; // @ts-ignore @@ -51,6 +46,7 @@ import Plus from '@react-spectrum/s2/icons/Add'; import {Popover, PopoverProps} from '@react-spectrum/s2/Popover'; import { Position, + SelectedRange, TokenFieldSegment, TokenFieldValue, TokenSegment @@ -73,6 +69,7 @@ import {useControlledState} from 'react-stately/useControlledState'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusableRef} from './useDOMRef'; import {useFocusWithin} from 'react-aria/useFocusWithin'; +import {useKeyboard} from 'react-aria/useKeyboard'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useVoiceInput, VoiceInputErrorCode} from './useVoiceInput'; @@ -86,9 +83,9 @@ export interface PromptFieldAttachment { export interface PromptFieldProps { children: React.ReactNode; acceptedAttachmentTypes?: string[]; - value?: TokenFieldValue; - defaultValue?: TokenFieldValue; - onChange?: (value: TokenFieldValue) => void; + value?: PromptFieldValue; + defaultValue?: PromptFieldValue; + onChange?: (value: PromptFieldValue) => void; attachments?: PromptFieldAttachment[]; defaultAttachments?: PromptFieldAttachment[]; onAttachmentsChange?: (attachments: PromptFieldAttachment[]) => void; @@ -106,8 +103,8 @@ interface PromptFieldState { attachments: PromptFieldAttachment[]; setAttachments: React.Dispatch>; acceptedAttachmentTypes?: string[]; - prompt: TokenFieldValue; - setPrompt: React.Dispatch>; + prompt: PromptFieldValue; + setPrompt: React.Dispatch>; inputRef: React.RefObject; onSubmit?: () => void; onStop?: () => void; @@ -145,10 +142,80 @@ function tokenizeURLs(text: string): TokenFieldSegment[] { return segments; } -export class PromptFieldValue extends TokenFieldValue { +interface UrlTokenValue { + type: 'url'; + url: string; +} + +interface PlaceholderTokenValue { + type: 'placeholder'; + placeholderType: 'token'; + /** Anchor character to insert when the user starts typing (e.g. '@'). */ + anchor: string; + /** Expected value type to filter completions by. */ + valueType: string | null; +} + +interface PlaceholderTextTokenValue { + type: 'placeholder'; + placeholderType: 'text'; +} + +interface AnchorTokenValue { + type: 'anchor'; + valueType: string; +} + +interface CustomTokenValue { + type: 'custom'; + /** Anchor character to insert when the user starts typing to replace the token (e.g. '@'). */ + anchor: string; + /** Type of the token value, used to filter replacement completions. */ + valueType: string; + /** Arbitrary token data. */ + data: any; +} + +export type PromptFieldTokenValue = + | UrlTokenValue + | PlaceholderTokenValue + | PlaceholderTextTokenValue + | AnchorTokenValue + | CustomTokenValue; + +export class PromptFieldValue extends TokenFieldValue { tokenize(text: string): TokenFieldSegment[] { return tokenizeURLs(text); } + + replaceRangeWithSegments( + start: Position, + end: Position, + segments: TokenFieldSegment[], + coalesce = true + ): this { + let slice = this.slice(start, end).segments; + let token = slice[0]; + if ( + slice.length === 1 && + token.type === 'token' && + ((token.value?.type === 'placeholder' && token.value.placeholderType === 'token') || + token.value?.type === 'custom') && + segments.length === 1 && + segments[0].type === 'text' && + !segments[0].text.startsWith(token.value.anchor) + ) { + segments = [ + { + type: 'token', + text: token.value.anchor, + value: {type: 'anchor', valueType: token.value.valueType} + }, + ...segments + ]; + } + return super.replaceRangeWithSegments(start, end, segments, coalesce); + } } const PromptFieldContext = createContext({ @@ -334,9 +401,10 @@ export function PromptFieldAttachmentList(props: PromptFieldAttachmentListProps) export interface PromptTokenFieldProps { completionTrigger?: RegExp; renderCompletions?: ( - filterValue: string + filterValue: string, + valueType: string | null ) => React.ReactNode[] | null | Promise; - children?: (segment: TokenSegment) => React.ReactElement; + children?: (segment: TokenSegment) => React.ReactElement; pixelLoader?: Cell[] | Cell[][]; placeholder?: string; onKeyDown?: (e: React.KeyboardEvent) => void; @@ -353,7 +421,7 @@ export function PromptTokenField(props: PromptTokenFieldProps) { pixelLoader, placeholder, menuWidth, - onKeyDown + onKeyDown: onKeyDownProp } = props; let { prompt, @@ -369,24 +437,75 @@ export function PromptTokenField(props: PromptTokenFieldProps) { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let [isFocused, setFocused] = useState(false); - let [filterAnchor, filterValue] = useMemo(() => { + let [filterAnchor, filterValue, filterType] = useMemo(() => { + // If on a placeholder token, show suggestions. + let slice = prompt.slice(prompt.selectedRange.start, prompt.selectedRange.end); + let segment = slice.segments.length === 1 ? slice.segments[0] : null; + if ( + segment?.type === 'token' && + ((segment.value?.type === 'placeholder' && segment.value.placeholderType === 'token') || + segment.value?.type === 'custom') + ) { + return [prompt.selectedRange.start, segment.value.anchor, segment.value.valueType ?? null]; + } + if (completionTrigger) { + // Find a preceding anchor token. This tells us what kind of object to filter for. + let anchorTokenIndex = -1; + let filterType: string | null = null; + for ( + let index = Math.min(prompt.selectedRange.anchor.index, prompt.segments.length - 1); + index >= 0; + index-- + ) { + let segment = prompt.segments[index]; + if (segment.type === 'token' && segment.value?.type === 'anchor') { + anchorTokenIndex = index; + filterType = segment.value?.valueType; + break; + } + } + let filterAnchor = prompt.findText( prompt.caretPosition, TokenFieldValue.Direction.Backward, completionTrigger ); + + // If anchor token is after text anchor, use it. + if (anchorTokenIndex >= 0 && (!filterAnchor || anchorTokenIndex > filterAnchor.index)) { + filterAnchor = {index: anchorTokenIndex, offset: 0}; + } + + // Filter text is the text between the anchor and the caret position. if (filterAnchor != null) { let filterValue = prompt.slice(filterAnchor, prompt.caretPosition).toString(); - return [filterAnchor, filterValue]; + return [filterAnchor, filterValue, filterType]; } } - return [null, null]; + return [null, null, null]; }, [completionTrigger, prompt]); let items = useMemo(() => { - return filterValue != null ? renderCompletions?.(filterValue) : null; - }, [filterValue, renderCompletions]); + return filterValue != null ? renderCompletions?.(filterValue, filterType) : null; + }, [filterValue, filterType, renderCompletions]); + + let tab = (dir: number) => { + let nextPrompt = selectNextToken(prompt, dir); + if (nextPrompt) { + setPrompt(nextPrompt); + return true; + } + return false; + }; + + let {keyboardProps} = useKeyboard({ + onKeyDown: onKeyDownProp, + shortcuts: { + Tab: () => tab(1), + 'Shift+Tab': () => tab(-1) + } + }); return (
{ if (e.isTrusted) { setFocused(true); + + // If shift tabbing into the prompt field, select the last placeholder if any. + if ( + e.relatedTarget && + getInteractionModality() === 'keyboard' && + e.currentTarget.compareDocumentPosition(e.relatedTarget) & + Node.DOCUMENT_POSITION_FOLLOWING + ) { + let lastPlaceholder = prompt.segments.findLastIndex(s => s.type === 'token'); + if (lastPlaceholder >= 0) { + setPrompt(value => + value.withSelectedRange( + new TokenFieldValue.SelectedRange( + {index: lastPlaceholder, offset: 0}, + {index: lastPlaceholder, offset: 1} + ) + ) + ); + } + } } }} onBlur={e => { @@ -451,7 +591,6 @@ export function PromptTokenField(props: PromptTokenFieldProps) { setFocused(false); } }} - onKeyDown={onKeyDown} onPaste={ acceptedAttachmentTypes ? e => { @@ -477,12 +616,12 @@ export function PromptTokenField(props: PromptTokenFieldProps) { + className={ css('&:empty::before { content: attr(data-placeholder); }') + style({ font: 'body', color: { - default: baseColor('neutral'), + default: 'neutral', ':empty': { default: 'gray-600', forcedColors: 'GrayText' @@ -491,9 +630,22 @@ export function PromptTokenField(props: PromptTokenFieldProps) { width: 'full', outlineStyle: 'none', cursor: 'text' - })(renderProps) + }) }> - {children || (segment => {segment.text})} + {useCallback( + (token: TokenSegment) => { + if (token.value?.type === 'anchor') { + return {token.text}; + } else { + return children ? ( + children(token) + ) : ( + {token.text} + ); + } + }, + [children] + )} = 0 && i < prompt.segments.length; i += dir) { + let segment = prompt.segments[i]; + if (segment.type === 'token' && (!placeholder || segment.value?.type === 'placeholder')) { + return prompt.withSelectedRange( + new TokenFieldValue.SelectedRange( + {index: i, offset: 0}, + {index: i, offset: segment.text.length} + ) + ); + } + } + + return null; +} + export interface PromptTokenFieldPopoverProps extends Omit { filterAnchor?: Position | null; items?: React.ReactNode[] | null | Promise; @@ -517,7 +690,7 @@ export interface PromptTokenFieldPopoverProps extends Omit 0 && filterAnchor.offset === 0) { + filterAnchor = { + index: filterAnchor.index - 1, + offset: prompt.segments[filterAnchor.index - 1].text.length + }; + } + // Reposition the popover when the anchor changes. + key = `${filterAnchor.index}:${filterAnchor.offset}`; + } + return ( { return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> - + {menuItems} @@ -548,6 +735,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { } export interface PromptTokenProps extends Omit { + token: TokenSegment; children: React.ReactNode; } @@ -555,40 +743,50 @@ export function PromptToken(props: PromptTokenProps) { return ( + className={renderProps => + style({ + font: 'ui', + backgroundColor: { + default: 'transparent-overlay-1000/10', + isSelected: 'blue-800', + // Firefox ignores completely transparent selection colors, so we need to use a nearly transparent color instead + '::selection': '[#ffffff01]' + }, + color: { + default: 'body', + isSelected: 'white' + }, + outlineStyle: { + default: 'solid', + isPlaceholder: 'dashed' + }, + outlineWidth: 1, + outlineColor: { + default: 'transparent-overlay-1000/10', + isPlaceholder: 'transparent-overlay-1000/40' + }, + outlineOffset: -1, + borderRadius: 'pill', + boxShadow: `[inset 0 24px 32px 0 ${color('transparent-white-50')}, 0 8px 32px 0 ${color('transparent-black-50')}]`, + paddingX: 8, + // not using inline-flex here due to a text selection bug in WebKit. + paddingY: space(3), + lineHeight: '[1em]', + cursor: 'default', + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } + })({...renderProps, isPlaceholder: props.token.value?.type === 'placeholder'}) + }> {icon} + styles: style({ + size: 14, + display: 'inline-block', + verticalAlign: '[-0.18em]', + marginEnd: 4 + }) }}> {props.children} @@ -674,7 +872,7 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { // to be inaccurate let finalPrompt = buildVoicePrompt(basePromptRef.current, transcript); inputRef.current.focus(); - setTokenFieldSelection(inputRef.current, finalPrompt.caretPosition, finalPrompt.caretPosition); + setTokenFieldSelection(inputRef.current, finalPrompt.selectedRange); setPrompt(finalPrompt); }); @@ -799,37 +997,50 @@ export function AttachFileMenuItem() { } // either replace the filter text (aka token replace) or insert value at current caret position (aka plain text inject) -function useInsertPromptSegment(buildSegments: (item: any) => TokenFieldSegment[]) { +function useInsertPromptSegment(segments: TokenFieldSegment[]) { let {setPrompt, inputRef} = useContext(PromptFieldContext); let anchor = useContext(PromptCompletionAnchorContext); - let pendingCaret = useRef(null); - return (item: any) => { + let pendingSelection = useRef(null); + return () => { setPrompt(value => { + // Add a space only if not already followed by one, but move the cursor past the space in any case. + let insert: TokenFieldSegment[] = [...segments]; + let endPosition = value.selectedRange.end; + if (insert.length) { + let space = value.findText(endPosition, TokenFieldValue.Direction.Forward, ' '); + let hasFollowingSpace = space && value.slice(endPosition, space).segments.length === 0; + insert.push({type: 'text', text: ' '}); + if (hasFollowingSpace && space) { + space.offset++; + endPosition = space; + } + } let newValue = value.replaceRangeWithSegments( - anchor ?? value.caretPosition, - value.caretPosition, - buildSegments(item), + anchor ?? value.selectedRange.start, + endPosition, + insert, false // Don't coalesce in undo/redo history. ); - pendingCaret.current = newValue.caretPosition; + newValue = selectNextToken(newValue, 1, true) || newValue; + pendingSelection.current = newValue.selectedRange; return newValue; }); if (anchor == null) { // Wait for popover animation, then restore cursor to after the inserted content. setTimeout(() => { - if (inputRef.current && pendingCaret.current) { - let position = pendingCaret.current; - pendingCaret.current = null; + if (inputRef.current && pendingSelection.current) { + let range = pendingSelection.current; + pendingSelection.current = null; inputRef.current.focus(); // we need to update the position manually since TokenField's update caret logic only happens if the field is focused // but this insert can happen from the + menu aka the field isn't focused until this gets called which is too late - setTokenFieldSelection(inputRef.current, position, position); + setTokenFieldSelection(inputRef.current, range); // the above focus and setCursor call can cause the internally tracked caret position to be reset incorrectly // seemingly due to TokenField's isProgrammaticSelectionChange being flipped to false by setCursor and thus reset to 0 by the .focus // fix this by resetting to proper position below // happens when injecting multiple tokens one after another via + menu - setPrompt(value => value.withCaretPosition(position)); + setPrompt(value => value.withSelectedRange(range)); } }, 400); } @@ -848,19 +1059,19 @@ export interface InsertTokenMenuItemProps extends Omit< | 'rel' | 'routerOptions' | 'target' -> {} + | 'value' +> { + token: TokenSegment; +} export function InsertTokenMenuItem(props: InsertTokenMenuItemProps) { - let insert = useInsertPromptSegment(item => [ - {type: 'token', text: 'command' in item ? item.command : item.title, value: item}, - {type: 'text', text: ' '} - ]); + let insert = useInsertPromptSegment([props.token]); return ( { - insert(props.value); + insert(); props.onAction?.(); }} /> @@ -879,18 +1090,19 @@ export interface InsertTextMenuItemProps extends Omit< | 'rel' | 'routerOptions' | 'target' -> {} + | 'value' +> { + text: string; +} export function InsertTextMenuItem(props: InsertTextMenuItemProps) { - let insert = useInsertPromptSegment(item => [ - {type: 'text', text: `${'command' in item ? item.command : item.title} `} - ]); + let insert = useInsertPromptSegment([{type: 'text', text: props.text}]); return ( { - insert(props.value); + insert(); props.onAction?.(); }} /> @@ -914,12 +1126,12 @@ export interface CommandMenuItemProps extends Omit< // since they dont end up inserting a token or text, we need to clear the partial text that the user used // to filter the menu export function CommandMenuItem(props: CommandMenuItemProps) { - let insert = useInsertPromptSegment(() => []); + let insert = useInsertPromptSegment([]); return ( { - insert(undefined); + insert(); props.onAction?.(); }} /> diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 715184cb6f9..344de025e87 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -215,7 +215,7 @@ export function VirtualizedStreamingChat() { let nextId = useRef(initialResponses.length); let [isGenerating, setGenerating] = useState(false); let timeouts = useRef([]); - let [promptValue, setPromptValue] = useState(new PromptFieldValue([])); + let [promptValue, setPromptValue] = useState(new PromptFieldValue([])); let followUpMessage = useRef(null); function handleSend(prompt: TokenFieldValue) { diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index ffd8099a434..d6a58abae94 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -21,6 +21,7 @@ import { PromptFieldAttachment, PromptFieldAttachmentList, PromptFieldSubmitButton, + PromptFieldTokenValue, PromptFieldToolbar, PromptFieldValue, PromptFieldVoiceButton, @@ -30,6 +31,7 @@ import { import {Attachment} from '../src/AttachmentList'; import Brand from '@react-spectrum/s2/icons/Brand'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; +import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import { Collection, Header, @@ -53,6 +55,7 @@ import Plugin from '@react-spectrum/s2/icons/Plugin'; import Prompt from '@react-spectrum/s2/icons/Prompt'; import SocialNetwork from '@react-spectrum/s2/icons/SocialNetwork'; import {TokenFieldValue} from 'react-aria-components'; +import {TokenSegment} from 'react-stately'; import {useRef, useState} from 'react'; import UserGroup from '@react-spectrum/s2/icons/UserGroup'; @@ -126,61 +129,78 @@ type Story = StoryObj; const slashCommands = [ { command: '/audience-explainer', - type: 'skill', + kind: 'skill', description: 'Explain an AEP audience in english' }, - {command: '/btw', type: 'command', description: 'Ask a side question'}, - {command: '/clear', type: 'command', description: 'Clear the context'}, - {command: '/compact', type: 'command', description: 'Summarize conversation history'}, - {command: '/dataset-usage', type: 'skill', description: 'Explain how to use a dataset'}, - {command: '/feedback', type: 'command', description: 'Submit feedback'}, - {command: '/plan', type: 'command', description: 'Create a plan before executing'}, - {command: '/visual-artifact', type: 'skill', description: 'Generate a chart or graph'} + {command: '/btw', kind: 'command', description: 'Ask a side question'}, + {command: '/clear', kind: 'command', description: 'Clear the context'}, + {command: '/compact', kind: 'command', description: 'Summarize conversation history'}, + {command: '/dataset-usage', kind: 'skill', description: 'Explain how to use a dataset'}, + {command: '/feedback', kind: 'command', description: 'Submit feedback'}, + {command: '/plan', kind: 'command', description: 'Create a plan before executing'}, + {command: '/visual-artifact', kind: 'skill', description: 'Generate a chart or graph'} ]; const icons = { - command: , - skill: , - audience: , - campaign: , - journey: , - url: + command: , + skill: , + audience: , + campaign: , + journey: , + url: } as const; +function getIcon(token: TokenSegment) { + switch (token.value?.type) { + case 'placeholder': + return token.value.placeholderType === 'token' && token.value.valueType + ? icons[token.value.valueType] + : null; + case 'url': + return icons.url; + case 'custom': + return icons[token.value.valueType]; + } +} + const objects = [ { section: 'Audiences', + type: 'audience', items: [ - {type: 'audience', title: 'New Customers'}, - {type: 'audience', title: 'Returning Customers'}, - {type: 'audience', title: 'Loyal Customers'}, - {type: 'audience', title: 'High-Value Customers'}, - {type: 'audience', title: 'Low-Value Customers'} + {kind: 'audience', title: 'New Customers'}, + {kind: 'audience', title: 'Returning Customers'}, + {kind: 'audience', title: 'Loyal Customers'}, + {kind: 'audience', title: 'High-Value Customers'}, + {kind: 'audience', title: 'Low-Value Customers'} ] }, { section: 'Campaigns', + type: 'campaign', items: [ - {type: 'campaign', title: 'Spring Launch 2026'}, - {type: 'campaign', title: 'Holiday Cheer'}, - {type: 'campaign', title: 'Back to School'}, - {type: 'campaign', title: 'Summer Adventure'}, - {type: 'campaign', title: 'Tech Trends Expo'} + {kind: 'campaign', title: 'Spring Launch 2026'}, + {kind: 'campaign', title: 'Holiday Cheer'}, + {kind: 'campaign', title: 'Back to School'}, + {kind: 'campaign', title: 'Summer Adventure'}, + {kind: 'campaign', title: 'Tech Trends Expo'} ] }, { section: 'Journeys', + type: 'journey', items: [ - {type: 'journey', title: 'Welcome Flow'}, - {type: 'journey', title: 'Abandoned Cart Recovery'}, - {type: 'journey', title: 'Post-Purchase Follow-up'}, - {type: 'journey', title: 'Re-engagement Campaign'}, - {type: 'journey', title: 'Birthday Surprise Journey'} + {kind: 'journey', title: 'Welcome Flow'}, + {kind: 'journey', title: 'Abandoned Cart Recovery'}, + {kind: 'journey', title: 'Post-Purchase Follow-up'}, + {kind: 'journey', title: 'Re-engagement Campaign'}, + {kind: 'journey', title: 'Birthday Surprise Journey'} ] } ]; interface CompletionCallbacks { + valueType?: string | null; onClear?: () => void; onCompact?: () => void; } @@ -188,7 +208,11 @@ interface CompletionCallbacks { function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) { if (filterValue.startsWith('/')) { return slashCommands - .filter(item => item.command.includes(filterValue.slice(1))) + .filter( + item => + item.command.includes(filterValue.slice(1)) && + (callbacks?.valueType ? item.kind === callbacks.valueType : true) + ) .map(item => item.command === '/clear' ? ( @@ -204,14 +228,21 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) ) : item.command === '/feedback' || item.command === '/btw' ? ( // coworker doesn't seem to have any text insertion commands anymore, so I added these for testing - + {item.command} {item.description} ) : ( - - {item.type === 'skill' ? : } + + {item.kind === 'skill' ? : } {item.command} {item.description} @@ -219,11 +250,19 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) ); } else if (filterValue.startsWith('@')) { return objects + .filter(section => (callbacks?.valueType ? section.type === callbacks.valueType : true)) .map(section => { let matchingItems = section.items .filter(item => item.title.toLowerCase().includes(filterValue.slice(1).toLowerCase())) .map(item => ( - + {item.title} )); @@ -258,7 +297,11 @@ function atEnd(v: PromptFieldValue) { let prompt1 = new PromptFieldValue([ {type: 'text', text: 'Analyze '}, - {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, + { + type: 'token', + text: 'New Customers', + value: {type: 'custom', anchor: '@', valueType: 'audience', data: {title: 'New Customers'}} + }, {type: 'text', text: ' and suggest targeting strategies'} ]); @@ -267,23 +310,42 @@ let prompt2 = new PromptFieldValue([ { type: 'token', text: 'Spring Launch 2026', - value: {type: 'campaign', title: 'Spring Launch 2026'} + value: {type: 'custom', anchor: '@', valueType: 'campaign', data: {title: 'Spring Launch 2026'}} } ]); let prompt3Base = new PromptFieldValue([ {type: 'text', text: 'Summarize the '}, - {type: 'token', text: 'Welcome Flow', value: {type: 'journey', title: 'Welcome Flow'}} + { + type: 'token', + text: 'Welcome Flow', + value: {type: 'custom', anchor: '@', valueType: 'journey', data: {title: 'Welcome Flow'}} + } ]); +let prompt4 = new PromptFieldValue( + [ + {type: 'text', text: 'Detect audiences in '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + }, + {type: 'text', text: ' that changed significantly in the past '}, + {type: 'token', text: 'date', value: {type: 'placeholder', placeholderType: 'text'}} + ] + // {selectedRange: new TokenFieldValue.SelectedRange({index: 1, offset: 0}, {index: 1, offset: 1})} +); + let prompts = [ - prompt1.withCaretPosition(atEnd(prompt1)), - prompt2.withCaretPosition(atEnd(prompt2)), + prompt1.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt1))), + prompt2.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt2))), prompt3Base.replaceRange( atEnd(prompt3Base), atEnd(prompt3Base), - ' journey performance from test.com /' - ) + ' journey performance from test.com ' + ), + prompt4 ]; function EverythingRender(args) { @@ -334,7 +396,37 @@ function EverythingRender(args) { setValue(prompt); promptFieldRef.current?.focus(); }}> - {prompt.toString()} + {prompt.segments.map((s, i) => + s.type === 'token' ? ( + + {getIcon(s) && {getIcon(s)}} + {s.text} + + ) : ( + s.text + ) + )} ))} @@ -395,22 +487,23 @@ function EverythingRender(args) { - renderCompletions(filterValue, { + renderCompletions={(filterValue, valueType) => { + return renderCompletions(filterValue, { + valueType, onClear: () => { setValue(new PromptFieldValue([])); setAttachments([]); }, onCompact: action('onCompact') - }) - } + }); + }} pixelLoader={data[args.pixelLoader]} placeholder={placeholder} menuWidth={menuWidth}> - {segment => ( - - {icons[segment.value?.type]} - {segment.text} + {token => ( + + {getIcon(token)} + {token.text} )} @@ -422,7 +515,7 @@ function EverythingRender(args) { Commands - item.type === 'command')}> + item.kind === 'command')}> {item => item.command === '/clear' ? ( {item.description} ) : item.command === '/feedback' || item.command === '/btw' ? ( - + {item.command} {item.description} ) : ( - + {item.command} {item.description} @@ -458,9 +557,15 @@ function EverythingRender(args) { Skills - item.type === 'skill')}> + item.kind === 'skill')}> {item => ( - + {item.command} {item.description} @@ -480,7 +585,15 @@ function EverythingRender(args) { {item => ( - {item.title} + + {item.title} + )} @@ -527,10 +640,10 @@ export const AsyncCompletions = () => ( await new Promise(resolve => setTimeout(resolve, 500)); return renderCompletions(filterValue); }}> - {segment => ( - - {icons[segment.value?.type]} - {segment.text} + {token => ( + + {getIcon(token)} + {token.text} )} diff --git a/packages/@react-spectrum/ai/test/PromptField.test.tsx b/packages/@react-spectrum/ai/test/PromptField.test.tsx index 84cbabd317e..6729af91ab9 100644 --- a/packages/@react-spectrum/ai/test/PromptField.test.tsx +++ b/packages/@react-spectrum/ai/test/PromptField.test.tsx @@ -10,12 +10,43 @@ * governing permissions and limitations under the License. */ +import {act, screen, waitFor} from '@react-spectrum/test-utils-internal'; +import { + imageAttachment, + installRangePolyfill, + PromptFieldValue, + renderPromptField, + tokenTexts +} from './utils/promptFieldTestUtils'; import {PromptField, PromptTokenField} from '../src/PromptField'; import React from 'react'; import {render} from '@react-spectrum/test-utils-internal'; import userEvent from '@testing-library/user-event'; +// Suite requires React 19 (matches the TokenField browser coverage this ports from). const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; + +let findMenuItem = (name: string | RegExp) => screen.findByRole('menuitem', {name}); +let getMenuItem = (name: string | RegExp) => screen.getByRole('menuitem', {name}); +let queryMenuItem = (name: string | RegExp) => screen.queryByRole('menuitem', {name}); + +let selectedText = (v: PromptFieldValue) => + v.slice(v.selectedRange.start, v.selectedRange.end).toString(); + +// A prompt containing both a fillable object placeholder (Journey) and a free-text placeholder (Date). +function placeholderPrompt(): PromptFieldValue { + return new PromptFieldValue([ + {type: 'text', text: 'Detect audiences in '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + }, + {type: 'text', text: ' that changed in the past '}, + {type: 'token', text: 'Date', value: {type: 'placeholder', placeholderType: 'text'}} + ]); +} + describeOrSkip('PromptField', () => { let user; @@ -23,6 +54,396 @@ describeOrSkip('PromptField', () => { user = userEvent.setup({delay: null}); }); + beforeAll(() => { + installRangePolyfill(); + }); + + describe('placeholder text', () => { + it('shows the placeholder when empty and hides it after typing', async () => { + let {user, textbox, getValue} = renderPromptField({placeholder: 'Ask me anything'}); + expect(textbox).toHaveAttribute('data-placeholder', 'Ask me anything'); + + await user.click(textbox); + await user.keyboard('hi'); + expect(getValue().toString()).toBe('hi'); + }); + }); + + describe('autocomplete trigger: @', () => { + it('opens object completions and inserts a token', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('@'); + + // All object sections are shown for a bare @ trigger. + expect(await findMenuItem('New Customers')).toBeInTheDocument(); + expect(getMenuItem('Spring Launch 2026')).toBeInTheDocument(); + expect(getMenuItem('Welcome Flow')).toBeInTheDocument(); + + await user.click(getMenuItem('New Customers')); + // Token replaces the typed filter and a trailing space is added. + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['New Customers'])); + expect(getValue().toString()).toBe('New Customers '); + }); + + it('filters completions as the user types', async () => { + let {user, textbox} = renderPromptField(); + await user.click(textbox); + await user.keyboard('@New'); + + expect(await findMenuItem('New Customers')).toBeInTheDocument(); + expect(queryMenuItem('Welcome Flow')).not.toBeInTheDocument(); + }); + + it('does not trigger when @ is not preceded by whitespace or start', async () => { + let {user, textbox} = renderPromptField(); + await user.click(textbox); + await user.keyboard('hello@'); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + }); + + describe('autocomplete trigger: /', () => { + it('inserts a token via InsertTokenMenuItem', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/'); + + expect(await findMenuItem('/audience-explainer')).toBeInTheDocument(); + expect(getMenuItem('/clear')).toBeInTheDocument(); + expect(getMenuItem('/compact')).toBeInTheDocument(); + expect(getMenuItem('/feedback')).toBeInTheDocument(); + + await user.click(getMenuItem('/audience-explainer')); + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['/audience-explainer'])); + }); + + it('inserts plain text via InsertTextMenuItem', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/feedback'); + + await user.click(await findMenuItem('/feedback')); + await waitFor(() => expect(getValue().toString()).toBe('/feedback ')); + expect(tokenTexts(getValue())).toEqual([]); + }); + + it('runs a callback and clears the filter via CommandMenuItem', async () => { + let {user, textbox, getValue, onCompact} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/compact'); + + await user.click(await findMenuItem('/compact')); + await waitFor(() => expect(onCompact).toHaveBeenCalledTimes(1)); + // Nothing inserted, and the filter text is cleared. + expect(tokenTexts(getValue())).toEqual([]); + expect(getValue().toString()).not.toContain('/compact'); + }); + + it('runs a callback via a plain MenuItem', async () => { + let {user, textbox, onClear} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/clear'); + + await user.click(await findMenuItem('/clear')); + await waitFor(() => expect(onClear).toHaveBeenCalledTimes(1)); + }); + }); + + describe('URL tokenization', () => { + it('auto-tokenizes a URL as it is typed', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('visit test.com now'); + + await waitFor(() => + expect(getValue().segments.some(s => s.type === 'token' && s.value?.type === 'url')).toBe( + true + ) + ); + let urlToken = getValue().segments.find(s => s.type === 'token' && s.value?.type === 'url'); + expect(urlToken?.text).toBe('test.com'); + }); + }); + + describe('replacing an existing token', () => { + it('opens same-type completions when a custom token is selected', async () => { + let initialValue = new PromptFieldValue([ + {type: 'text', text: 'Analyze '}, + { + type: 'token', + text: 'New Customers', + value: { + type: 'custom', + anchor: '@', + valueType: 'audience', + data: {kind: 'audience', title: 'New Customers'} + } + } + ]); + let {user, textbox, getValue, setValue} = renderPromptField({initialValue}); + + // Select the token via the controlled value (jsdom can't click-select a token), then the + // completions open filtered to the same type (audiences). + await user.click(textbox); + act(() => + setValue( + v => + v.withSelectedRange( + new PromptFieldValue.SelectedRange( + {index: 1, offset: 0}, + {index: 1, offset: 'New Customers'.length} + ) + ) as PromptFieldValue + ) + ); + expect(await findMenuItem('Returning Customers')).toBeInTheDocument(); + expect(queryMenuItem('Welcome Flow')).not.toBeInTheDocument(); + expect(queryMenuItem('Spring Launch 2026')).not.toBeInTheDocument(); + + // Choosing a different audience replaces the selected token. + await user.click(getMenuItem('Returning Customers')); + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['Returning Customers'])); + }); + }); + + describe('insert (+) menu', () => { + it('inserts an object token from the Reference submenu', async () => { + let {user, textbox, getValue} = renderPromptField(); + // Type first so the field has a live caret to insert at (typical usage). + await user.click(textbox); + await user.keyboard('Use '); + await user.click(screen.getByRole('button', {name: 'Add'})); + + expect(await findMenuItem('Attach a file')).toBeInTheDocument(); + let referenceItem = getMenuItem('Reference an object'); + + // Open the submenu. + await user.hover(referenceItem); + await user.click(await findMenuItem('Welcome Flow')); + + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['Welcome Flow'])); + expect(getValue().toString()).toBe('Use Welcome Flow '); + }); + + it('does not insert a double space when the caret is already followed by one', async () => { + let {user, getValue, setValue} = renderPromptField(); + // Place the caret between 'x' and the space (jsdom can't move the caret via arrow keys). + act(() => + setValue( + new PromptFieldValue([{type: 'text', text: 'x y'}]).withSelectedRange( + new PromptFieldValue.SelectedRange({index: 0, offset: 1}) + ) as PromptFieldValue + ) + ); + + await user.click(screen.getByRole('button', {name: 'Add'})); + await user.hover(getMenuItem('Reference an object')); + await user.click(await findMenuItem('Welcome Flow')); + + // Reuses the existing trailing space rather than adding a second one. + await waitFor(() => expect(getValue().toString()).toBe('xWelcome Flow y')); + }); + }); + + describe('placeholders', () => { + it('moves between placeholders with Tab and Shift+Tab', async () => { + let {user, textbox, getValue} = renderPromptField({initialValue: placeholderPrompt()}); + await user.click(textbox); + await user.keyboard('{Home}'); + + // Tab selects the first placeholder (Journey, index 1). + await user.keyboard('{Tab}'); + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 1, offset: 0})); + expect(getValue().selectedRange.end).toEqual({index: 1, offset: 'Journey'.length}); + + // Tab again selects the next placeholder (Date, index 3). + await user.keyboard('{Tab}'); + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 3, offset: 0})); + expect(getValue().selectedRange.end).toEqual({index: 3, offset: 'Date'.length}); + + // Shift+Tab goes back to the Journey placeholder. + await user.keyboard('{Shift>}{Tab}{/Shift}'); + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 1, offset: 0})); + }); + + it('re-opens completions filtered to the placeholder value type when typed over', async () => { + let {user, textbox} = renderPromptField({initialValue: placeholderPrompt()}); + await user.click(textbox); + await user.keyboard('{Home}'); + await user.keyboard('{Tab}'); // select the Journey placeholder + await user.keyboard('W'); + + // Completions re-open, filtered to journeys only. + expect(await findMenuItem('Welcome Flow')).toBeInTheDocument(); + expect(queryMenuItem('New Customers')).not.toBeInTheDocument(); + }); + + it('selects the last token when Shift+Tab-ing into the field', async () => { + let {user, textbox, getValue, setValue} = renderPromptField({ + initialValue: placeholderPrompt() + }); + await user.click(textbox); + // Put the caret past the last placeholder so Tab leaves the field instead of jumping + // placeholders (jsdom can't move the caret to the end via {End}). + act(() => + setValue( + v => + v.withSelectedRange( + new PromptFieldValue.SelectedRange({index: 3, offset: 'Date'.length}) + ) as PromptFieldValue + ) + ); + await user.keyboard('{Tab}'); + // Re-enter from a following element; the last placeholder (Date, index 3) is auto-selected. + await user.keyboard('{Shift>}{Tab}{/Shift}'); + + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 3, offset: 0})); + // The selection covers the last placeholder (the Date token). + expect(selectedText(getValue())).toBe('Date'); + }); + + it('tabs through all tokens, not just placeholders', async () => { + let initialValue = new PromptFieldValue([ + {type: 'text', text: 'Analyze '}, + { + type: 'token', + text: 'New Customers', + value: { + type: 'custom', + anchor: '@', + valueType: 'audience', + data: {kind: 'audience', title: 'New Customers'} + } + }, + {type: 'text', text: ' and '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + } + ]); + let {user, textbox, getValue} = renderPromptField({initialValue}); + await user.click(textbox); + await user.keyboard('{Home}'); + + // Tab selects the first token (a non-placeholder custom token). + await user.keyboard('{Tab}'); + await waitFor(() => expect(selectedText(getValue())).toBe('New Customers')); + + // Tab again selects the next token (the placeholder). + await user.keyboard('{Tab}'); + await waitFor(() => expect(selectedText(getValue())).toBe('Journey')); + + await user.keyboard('{Tab}'); + await user.keyboard('{Shift>}{Tab}{/Shift}'); + expect(selectedText(getValue())).toBe('Journey'); + }); + + it('advances the selection to the next placeholder after filling a placeholder', async () => { + let {user, textbox, getValue} = renderPromptField({initialValue: placeholderPrompt()}); + await user.click(textbox); + await user.keyboard('{Home}'); + await user.keyboard('{Tab}'); // select the Journey placeholder + + await user.click(await findMenuItem('Welcome Flow')); + + // Filling the placeholder auto-advances the selection to the next placeholder (Date). + await waitFor(() => expect(selectedText(getValue())).toBe('Date')); + }); + + it('does not advance the selection onto a following non-placeholder token', async () => { + let initialValue = new PromptFieldValue([ + {type: 'text', text: 'in '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + }, + {type: 'text', text: ' for '}, + { + type: 'token', + text: 'New Customers', + value: { + type: 'custom', + anchor: '@', + valueType: 'audience', + data: {kind: 'audience', title: 'New Customers'} + } + } + ]); + let {user, textbox, getValue} = renderPromptField({initialValue}); + await user.click(textbox); + await user.keyboard('{Home}'); + await user.keyboard('{Tab}'); // select the Journey placeholder + + await user.click(await findMenuItem('Welcome Flow')); + + // No placeholder follows, so the selection collapses to a caret rather than selecting the + // non-placeholder token (auto-advance only targets placeholders). + await waitFor(() => expect(getValue().selectedRange.isCollapsed).toBe(true)); + expect(selectedText(getValue())).toBe(''); + expect(tokenTexts(getValue())).toEqual(['Welcome Flow', 'New Customers']); + }); + }); + + describe('submit / generate state', () => { + it('disables submit when empty and enables it with content', async () => { + let {user, textbox, getValue, onSubmit} = renderPromptField(); + let submit = screen.getByRole('button', {name: 'Send'}); + expect(submit).toBeDisabled(); + + await user.click(textbox); + await user.keyboard('hello'); + await waitFor(() => expect(getValue().toString()).toBe('hello')); + expect(submit).toBeEnabled(); + + await user.click(submit); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].toString()).toBe('hello'); + }); + + it('shows a Stop button while generating and calls onStop', async () => { + let {user, onStop} = renderPromptField({isGenerating: true}); + let stop = screen.getByRole('button', {name: 'Stop'}); + expect(stop).toBeEnabled(); + + await user.click(stop); + expect(onStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('attachments', () => { + it('renders attachments and removes them', async () => { + let attachment = imageAttachment('a1'); + let {user, container, getAttachments, onRemoveAttachments} = renderPromptField({ + attachments: [attachment] + }); + + expect(screen.getByLabelText('Attachments')).toBeInTheDocument(); + let removeButton = container.querySelector('[slot="remove"]') as HTMLElement; + expect(removeButton).toBeInTheDocument(); + + await user.click(removeButton); + expect(onRemoveAttachments).toHaveBeenCalledTimes(1); + expect(onRemoveAttachments.mock.calls[0][0][0].id).toBe('a1'); + await waitFor(() => expect(getAttachments().length).toBe(0)); + }); + + it('shows upload progress while uploading', () => { + renderPromptField({attachments: [imageAttachment('a1')], uploadProgress: 50}); + expect(screen.getByRole('progressbar', {name: 'Uploading'})).toBeInTheDocument(); + }); + + it('renders an attachment in the invalid state', () => { + let {container} = renderPromptField({attachments: [imageAttachment('a1')], invalid: true}); + expect(screen.getByLabelText('Attachments')).toBeInTheDocument(); + // The invalid state renders a decorative alert icon. + expect(container.querySelector('[aria-hidden="true"] svg')).toBeTruthy(); + }); + }); + it('fires onKeyDown when a key is pressed in the token field', async () => { let onKeyDown = jest.fn(); let {getByRole} = render( diff --git a/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx b/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx new file mode 100644 index 00000000000..926d308a47b --- /dev/null +++ b/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx @@ -0,0 +1,362 @@ +/* + * 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 { + AttachFileMenuItem, + CommandMenuItem, + InsertMenuButton, + InsertTextMenuItem, + InsertTokenMenuItem, + PromptField, + PromptFieldAttachment, + PromptFieldAttachmentList, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldValue, + PromptToken, + PromptTokenField +} from '../../src/PromptField'; +import {Attachment} from '../../src/AttachmentList'; +import { + Collection, + Header, + Heading, + Menu, + MenuItem, + MenuSection, + SubmenuTrigger, + Text +} from '@react-spectrum/s2/Menu'; +import {Image} from '@react-spectrum/s2/Image'; +import {pointerMap, render} from '@react-spectrum/test-utils-internal'; +import React, {useEffect, useState} from 'react'; +import {TokenFieldValue} from 'react-aria-components'; +import userEvent from '@testing-library/user-event'; + +// Tiny transparent PNG so resolves without a network fetch. +export const TINY_PNG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + +/** + * Jsdom doesn't implement Range.getBoundingClientRect / getClientRects, which the completion + * popover relies on for positioning. Install stubs so the popover can open. Call in beforeAll. + */ +export function installRangePolyfill(): void { + let proto = Range.prototype as any; + if (!proto.getBoundingClientRect) { + proto.getBoundingClientRect = () => ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON() {} + }); + } + if (!proto.getClientRects) { + proto.getClientRects = () => ({ + length: 0, + item: () => null, + [Symbol.iterator]: function* () {} + }); + } +} + +// Completion data, trimmed from PromptField.stories.tsx. +export const slashCommands = [ + {command: '/audience-explainer', kind: 'skill', description: 'Explain an AEP audience'}, + {command: '/clear', kind: 'command', description: 'Clear the context'}, + {command: '/compact', kind: 'command', description: 'Summarize conversation history'}, + {command: '/feedback', kind: 'command', description: 'Submit feedback'} +]; + +export const objects = [ + { + section: 'Audiences', + type: 'audience', + items: [ + {kind: 'audience', title: 'New Customers'}, + {kind: 'audience', title: 'Returning Customers'} + ] + }, + { + section: 'Campaigns', + type: 'campaign', + items: [{kind: 'campaign', title: 'Spring Launch 2026'}] + }, + { + section: 'Journeys', + type: 'journey', + items: [ + {kind: 'journey', title: 'Welcome Flow'}, + {kind: 'journey', title: 'Abandoned Cart Recovery'} + ] + } +]; + +interface CompletionCallbacks { + valueType?: string | null; + onClear?: () => void; + onCompact?: () => void; +} + +export function renderCompletions( + filterValue: string, + callbacks?: CompletionCallbacks +): React.ReactNode[] | null { + if (filterValue.startsWith('/')) { + return slashCommands + .filter( + item => + item.command.includes(filterValue.slice(1)) && + (callbacks?.valueType ? item.kind === callbacks.valueType : true) + ) + .map(item => + item.command === '/clear' ? ( + + {item.command} + + ) : item.command === '/compact' ? ( + + {item.command} + + ) : item.command === '/feedback' ? ( + + {item.command} + + ) : ( + + {item.command} + + ) + ); + } else if (filterValue.startsWith('@')) { + return objects + .filter(section => (callbacks?.valueType ? section.type === callbacks.valueType : true)) + .map(section => { + let matchingItems = section.items + .filter(item => item.title.toLowerCase().includes(filterValue.slice(1).toLowerCase())) + .map(item => ( + + {item.title} + + )); + return matchingItems.length > 0 ? ( + +
+ {section.section} +
+ {matchingItems} +
+ ) : null; + }) + .filter((v): v is React.ReactElement => v != null); + } + return null; +} + +export interface HarnessOptions { + initialValue?: PromptFieldValue; + attachments?: PromptFieldAttachment[]; + isGenerating?: boolean; + placeholder?: string; + acceptedAttachmentTypes?: string[]; + /** Applied to every rendered attachment (for exercising the upload progress state). */ + uploadProgress?: number; + /** Renders every attachment in the invalid state. */ + invalid?: boolean; +} + +export interface HarnessSpies { + onSubmit: jest.Mock; + onStop: jest.Mock; + onClear: jest.Mock; + onCompact: jest.Mock; + onRemoveAttachments: jest.Mock; +} + +interface ControlledPromptFieldProps extends HarnessOptions { + valueRef: React.MutableRefObject; + attachmentsRef: React.MutableRefObject; + setValueRef: React.MutableRefObject>>; + spies: HarnessSpies; +} + +function ControlledPromptField(props: ControlledPromptFieldProps) { + let { + initialValue = new PromptFieldValue([]), + attachments: initialAttachments = [], + isGenerating, + placeholder, + acceptedAttachmentTypes = ['image/*'], + uploadProgress, + invalid, + valueRef, + attachmentsRef, + setValueRef, + spies + } = props; + let [value, setValue] = useState(initialValue); + let [attachments, setAttachments] = useState(initialAttachments); + useEffect(() => { + setValueRef.current = setValue; + }, [setValue, setValueRef]); + useEffect(() => { + valueRef.current = value; + }, [value, valueRef]); + useEffect(() => { + attachmentsRef.current = attachments; + }, [attachments, attachmentsRef]); + + return ( + setValue(v as PromptFieldValue)} + attachments={attachments} + onAttachmentsChange={setAttachments} + isGenerating={isGenerating} + onStop={spies.onStop} + onSubmit={spies.onSubmit} + acceptedAttachmentTypes={acceptedAttachmentTypes} + onRemoveAttachments={spies.onRemoveAttachments}> + + {attachment => ( + + {attachment.image && } + + )} + + + renderCompletions(filterValue, { + valueType, + onClear: spies.onClear, + onCompact: spies.onCompact + }) + }> + {token => {token.text}} + + + + + + + Reference an object + + + {(item: (typeof objects)[number]) => ( + +
+ {item.section} +
+ + {(obj: {kind: string; title: string}) => ( + + {obj.title} + + )} + +
+ )} +
+
+
+ +
+
+ ); +} + +export interface PromptFieldHarness extends HarnessSpies { + user: ReturnType; + getValue: () => PromptFieldValue; + getAttachments: () => PromptFieldAttachment[]; + /** + * The controlled value setter. jsdom can't drive caret/token selection through the + * contenteditable (that needs Selection.modify / hit-testing, covered by TokenField's own + * browser tests), so tests position the caret/selection through the controlled value instead. + */ + setValue: React.Dispatch>; + textbox: HTMLElement; + container: HTMLElement; +} + +export function renderPromptField(options: HarnessOptions = {}): PromptFieldHarness { + let user = userEvent.setup({delay: null, pointerMap}); + let valueRef = {current: options.initialValue ?? new PromptFieldValue([])}; + let attachmentsRef = {current: options.attachments ?? []}; + let setValueRef = {current: (() => {}) as React.Dispatch>}; + let spies: HarnessSpies = { + onSubmit: jest.fn(), + onStop: jest.fn(), + onClear: jest.fn(), + onCompact: jest.fn(), + onRemoveAttachments: jest.fn() + }; + let tree = render( + + ); + return { + ...spies, + user, + getValue: () => valueRef.current, + getAttachments: () => attachmentsRef.current, + setValue: (...args) => setValueRef.current(...args), + textbox: tree.getByRole('textbox', {name: 'Prompt'}), + container: tree.container + }; +} + +/** Build an image attachment fixture backed by a real File. */ +export function imageAttachment(id: string, name = 'photo.png'): PromptFieldAttachment { + return {id, file: new File(['x'], name, {type: 'image/png'}), image: TINY_PNG}; +} + +export function tokenTexts(value: PromptFieldValue): string[] { + return value.segments.filter(s => s.type === 'token').map(s => s.text); +} + +export {PromptFieldValue, TokenFieldValue}; diff --git a/packages/react-aria-components/test/TokenField.browser.test.tsx b/packages/react-aria-components/test/TokenField.browser.test.tsx index 062a786665d..a5e0d6b7f7b 100644 --- a/packages/react-aria-components/test/TokenField.browser.test.tsx +++ b/packages/react-aria-components/test/TokenField.browser.test.tsx @@ -1135,5 +1135,23 @@ describeOrSkip('TokenField browser interactions', () => { } await waitForFieldText(getValue, 'b'); }); + + it('restores the selection when undoing a replacement', async () => { + let list = segments(text('abcde')); + let {textbox, getValue} = await renderControlledTokenField(list); + let el = textbox.element(); + await focusField(textbox); + // Select "bcd". + setFieldSelection(el, {index: 0, offset: 1}, {index: 0, offset: 4}); + await waitForSelection(textbox, {index: 0, offset: 1}, {index: 0, offset: 4}); + // Replace the selection by typing. + await userEvent.keyboard('X'); + await waitForFieldText(getValue, 'aXe'); + // Undo restores both the text and the selection that was replaced. + let mod = modKey(); + await userEvent.keyboard(`{${mod}>}z{/${mod}}`); + await waitForFieldText(getValue, 'abcde'); + await waitForSelection(textbox, {index: 0, offset: 1}, {index: 0, offset: 4}); + }); }); }); diff --git a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx index e9a56002477..d066980ddf9 100644 --- a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx +++ b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx @@ -102,7 +102,7 @@ export async function focusField(locator: Locator) { } export function setFieldSelection(textboxEl: Element, start: Position, end: Position): void { - setTokenFieldSelection(textboxEl, start, end); + setTokenFieldSelection(textboxEl, new TokenFieldValue.SelectedRange(start, end)); } /** diff --git a/packages/react-aria/src/tokenfield/useToken.ts b/packages/react-aria/src/tokenfield/useToken.ts index 9f51ed2ec75..15c9547b5b8 100644 --- a/packages/react-aria/src/tokenfield/useToken.ts +++ b/packages/react-aria/src/tokenfield/useToken.ts @@ -36,12 +36,12 @@ export function useToken( useEvent(useRef(typeof document !== 'undefined' ? document : null), 'selectionchange', () => { let selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || !ref.current) { + if (!selection || !ref.current) { return; } - let range = selection.getRangeAt(0); - if (!range.collapsed && range.intersectsNode(ref.current)) { + let range = selection.rangeCount === 0 ? null : selection.getRangeAt(0); + if (!range?.collapsed && range?.intersectsNode(ref.current)) { setSelected(true); } else { setSelected(false); diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index aef7c40d9f2..3dfdb1eff2e 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -20,12 +20,13 @@ import { useMemo, useRef } from 'react'; -import {getActiveElement} from '../utils/shadowdom/DOMFunctions'; +import {getActiveElement, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {getOwnerDocument} from '../utils/domHelpers'; import {isMac} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; import { Position, + SelectedRange, TokenFieldProps, TokenFieldSegment, TokenFieldState, @@ -175,19 +176,15 @@ export function useTokenField( nextValue.current = value; }); - let caretPosition = useRef(null); + let selectedRange = useRef(null); useLayoutEffect(() => { - if ( - ref.current && - value.caretPosition && - !state.isComposing && - value.caretPosition !== caretPosition.current - ) { + if (ref.current && !state.isComposing && value.selectedRange !== selectedRange.current) { // Only move the caret when the field is already focused. if (ref.current === getActiveElement(getOwnerDocument(ref.current))) { - setCursor(ref.current, value.caretPosition); + setTokenFieldSelection(ref.current, value.selectedRange); + announceToken(value); } - caretPosition.current = value.caretPosition; + selectedRange.current = value.selectedRange; } }); @@ -393,20 +390,29 @@ export function useTokenField( // When the cursor moves next to a token, announce it. // Otherwise the screen reader will only announce the first/last character. - if (window.getSelection()?.isCollapsed) { - let [start, end] = getSelection(ref.current!)!; - if (start.offset === 0) { - let segment = value.segments[start.index]; - if (segment?.type !== 'token') { - segment = value.segments[start.index - 1]; - } - if (segment?.type === 'token') { - announce(segment.text, 'assertive'); - } + let range = getSelectedRange(ref.current!); + if (!range) { + return; + } - // Update the caret position in the value. - state.setValue(value => value.withCaretPosition(end)); - } + announceToken(value, range); + + // Update the selected range in the value. + state.setValue(value => value.withSelectedRange(range)); + }); + + // Clear selection on blur. + useEvent(ref, 'blur', e => { + if (!e.isTrusted) { + return; + } + + let selection = window.getSelection(); + if (ref.current && selection && selection.containsNode(ref.current, true)) { + selection.removeAllRanges(); + state.setValue(value => + value.withSelectedRange(new TokenFieldValue.SelectedRange(value.caretPosition)) + ); } }); @@ -423,7 +429,7 @@ export function useTokenField( let end = value.findLineBoundary(selection[1], TokenFieldValue.Direction.Forward); if (start && end) { e.preventDefault(); - setTokenFieldSelection(ref.current!, start, end, true); + setTokenFieldSelection(ref.current!, new TokenFieldValue.SelectedRange(start, end), true); } } }); @@ -613,13 +619,34 @@ export function getSelection(container: Element): [Position, Position] | null { return rangeToPositions(container, range); } +export function getSelectedRange(container: Element) { + let selection = window.getSelection(); + if ( + !selection || + !selection.anchorNode || + !selection.focusNode || + !nodeContains(container, selection.anchorNode) || + !nodeContains(container, selection.focusNode) + ) { + return null; + } + let anchor = getPosition(container, selection.anchorNode, selection.anchorOffset, false); + let current = getPosition( + container, + selection.focusNode, + selection.focusOffset, + !selection.isCollapsed + ); + return new TokenFieldValue.SelectedRange(anchor, current); +} + function rangeToPositions(container: Element, range: Range | StaticRange): [Position, Position] { - let start = getPosition(container, range.startContainer, range.startOffset); - let end = getPosition(container, range.endContainer, range.endOffset); + let start = getPosition(container, range.startContainer, range.startOffset, false); + let end = getPosition(container, range.endContainer, range.endOffset, !range.collapsed); return [start, end]; } -function getPosition(container: Element, node: Node, offset: number): Position { +function getPosition(container: Element, node: Node, offset: number, isRangeEnd = false): Position { if (node === container) { return {index: offset, offset: 0}; } @@ -636,7 +663,7 @@ function getPosition(container: Element, node: Node, offset: number): Position { let endOffset = 0; if (originalNode === tokenNode) { // Cursor is inside the token. - atEnd = offset > 0; + atEnd = isRangeEnd || offset > 0; } else if (originalNode === node) { // Cursor is inside the wrapper element. atEnd = offset > 1; @@ -666,57 +693,84 @@ function getPosition(container: Element, node: Node, offset: number): Position { let isProgrammaticSelectionChange = Symbol('isProgrammaticSelectionChange'); function setCursor(root: Element, pos: Position, fireEvent = false) { - setTokenFieldSelection(root, pos, pos, fireEvent); + setTokenFieldSelection(root, new TokenFieldValue.SelectedRange(pos), fireEvent); } export function setTokenFieldSelection( root: Element, - start: Position, - end: Position, + selectedRange: SelectedRange, fireEvent = false ) { let selection = window.getSelection(); if (selection) { - let range = createDOMRange(root, start, end); + // Use setBaseAndExtent to preserve the selection direction. A plain Range + + // addRange always produces a forward selection and collapses when the + // anchor comes after the current position (backward selections). + let [anchorNode, anchorOffset] = getDOMPosition(root, selectedRange.anchor); + let [focusNode, focusOffset] = getDOMPosition(root, selectedRange.current); root[isProgrammaticSelectionChange] = !fireEvent; - selection.removeAllRanges(); - selection.addRange(range); + + // Only set selection if it has changed, because this can clobber the browser's selection direction. + if ( + selection.anchorNode !== anchorNode || + selection.anchorOffset !== anchorOffset || + selection.focusNode !== focusNode || + selection.focusOffset !== focusOffset + ) { + selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); + } } } export function tokenFieldPositionToDOMRange(root: Element, pos: Position): Range { - return createDOMRange(root, pos, pos); + // Unlike createDOMRange (used for caret/selection placement), this range is only + // measured via getBoundingClientRect to position things like an autocomplete popover. + // Place the endpoints inside the token's zero width space wrappers so the range has a + // valid rect at the token, rather than a collapsed root-level position. + let range = document.createRange(); + let [startContainer, startOffset] = getDOMRectPosition(root, pos); + range.setStart(startContainer, startOffset); + range.setEnd(startContainer, startOffset); + return range; } -function createDOMRange(root: Element, start: Position, end: Position): Range { - let range = document.createRange(); - let startChild = root.childNodes[start.index]; - if (!startChild) { - range.setStart(root, Math.min(root.childNodes.length, start.index)); - } else if (startChild.nodeType === Node.ELEMENT_NODE) { - // Place the cursor outside the token wrapper element. - if (start.offset > 0) { - range.setStartAfter(startChild); +function getDOMRectPosition(root: Element, pos: Position): [Node, number] { + let child = root.childNodes[pos.index]; + if (child && child.nodeType === Node.ELEMENT_NODE) { + // Place the position inside the zero width space wrappers around the token. + if (pos.offset > 0) { + return [child.lastChild!, 1]; } else { - range.setStartBefore(startChild); + return [child.firstChild!, 0]; } - } else { - range.setStart(startChild, start.offset); } + return getDOMPosition(root, pos); +} - let endChild = root.childNodes[end.index]; - if (!endChild) { - range.setEnd(root, Math.min(root.childNodes.length, end.index)); - } else if (endChild.nodeType === Node.ELEMENT_NODE) { - if (end.offset > 0) { - range.setEndAfter(endChild); +function createDOMRange(root: Element, start: Position, end: Position): Range { + let range = document.createRange(); + let [startContainer, startOffset] = getDOMPosition(root, start); + let [endContainer, endOffset] = getDOMPosition(root, end); + range.setStart(startContainer, startOffset); + range.setEnd(endContainer, endOffset); + return range; +} + +function getDOMPosition(root: Element, pos: Position): [Node, number] { + let child = root.childNodes[pos.index]; + if (!child) { + return [root, Math.min(root.childNodes.length, pos.index)]; + } else if (child.nodeType === Node.ELEMENT_NODE) { + // Place the cursor outside the token wrapper element. + // This is necessary for composition events. + if (pos.offset > 0) { + return [root, pos.index + 1]; } else { - range.setEndBefore(endChild); + return [root, pos.index]; } } else { - range.setEnd(endChild, end.offset); + return [child, pos.offset]; } - return range; } function isSamePosition(a: Position, b: Position): boolean { @@ -832,3 +886,26 @@ function trackMutations(element: Element) { } }; } + +function announceToken(value: TokenFieldValue, range = value.selectedRange) { + if (range.isCollapsed) { + // Announce adjacent tokens. + let segment = value.segments[range.current.index]; + if (segment && segment.type !== 'token') { + if (range.current.offset === 0) { + segment = value.segments[range.current.index - 1]; + } else if (range.current.offset === segment.text.length) { + segment = value.segments[range.current.index + 1]; + } + } + if (segment?.type === 'token') { + announce(segment.text, 'assertive'); + } + } else { + // Announce token if it is the only thing selected. + let selected = value.slice(range.start, range.end).segments; + if (selected.length === 1 && selected[0].type === 'token') { + announce(selected[0].text, 'assertive'); + } + } +} diff --git a/packages/react-stately/exports/useTokenFieldState.ts b/packages/react-stately/exports/useTokenFieldState.ts index e6ad50ac607..98e3a375c20 100644 --- a/packages/react-stately/exports/useTokenFieldState.ts +++ b/packages/react-stately/exports/useTokenFieldState.ts @@ -19,5 +19,6 @@ export type { TokenSegment, TextSegment, Position, + SelectedRange, TokenFieldValueOptions } from '../src/tokenfield/TokenFieldValue'; diff --git a/packages/react-stately/src/tokenfield/TokenFieldValue.ts b/packages/react-stately/src/tokenfield/TokenFieldValue.ts index dd8ddd1c3c2..96ac3caddeb 100644 --- a/packages/react-stately/src/tokenfield/TokenFieldValue.ts +++ b/packages/react-stately/src/tokenfield/TokenFieldValue.ts @@ -31,13 +31,66 @@ export interface Position { offset: number; } +/** Represents a text selection in a TokenField. */ +export class SelectedRange { + /** The anchor position. */ + anchor: Position; + /** The current (i.e. caret) position. */ + current: Position; + + /** + * Creates a new selection range. If only a single position is provided, the selection is + * collapsed. + */ + constructor(anchor: Position, current: Position = anchor) { + this.anchor = anchor; + this.current = current; + } + + /** Whether the selection is collapsed to a single caret position. */ + get isCollapsed() { + return this.anchor.index === this.current.index && this.anchor.offset === this.current.offset; + } + + /** The side of the selection closest to the start of the value. */ + get start() { + return compare(this.anchor, this.current) < 0 ? this.anchor : this.current; + } + + /** The side of the selection closest to the end of the value. */ + get end() { + return compare(this.anchor, this.current) < 0 ? this.current : this.anchor; + } + + /** Returns whether this selection is equal to another. */ + isEqual(other: SelectedRange) { + if (this === other) { + return true; + } + return ( + this.anchor.index === other.anchor.index && + this.anchor.offset === other.anchor.offset && + this.current.index === other.current.index && + this.current.offset === other.current.offset + ); + } +} + +function compare(a: Position, b: Position) { + if (a.index === b.index) { + return a.offset - b.offset; + } + + return a.index - b.index; +} + enum Direction { Forward = 1, Backward = -1 } export interface TokenFieldValueOptions { - caretPosition?: Position | null; + selectedRange?: SelectedRange | null; } /** @@ -45,11 +98,12 @@ export interface TokenFieldValueOptions { */ export class TokenFieldValue { static readonly Direction = Direction; + static readonly SelectedRange = SelectedRange; /** The text and token segments in the list. */ readonly segments: readonly TokenFieldSegment[]; - /** The caret position. */ - caretPosition: Position = {index: 0, offset: 0}; + /** The selected range. */ + selectedRange: SelectedRange; // Linked list representing the undo/redo history. private previous: this | null = null; private next: this | null = null; @@ -58,7 +112,7 @@ export class TokenFieldValue { /** Create a new list with the given segments. */ constructor(tokens: readonly TokenFieldSegment[], options?: TokenFieldValueOptions) { this.segments = tokens; - this.caretPosition = options?.caretPosition ?? {index: 0, offset: 0}; + this.selectedRange = options?.selectedRange ?? new SelectedRange({index: 0, offset: 0}); } protected createFieldValue(segments: readonly TokenFieldSegment[]): this { @@ -69,23 +123,28 @@ export class TokenFieldValue { return new Constructor(segments); } + get caretPosition(): Position { + return this.selectedRange.current; + } + /** Create a new list with the caret position set to the given position. */ - withCaretPosition(caretPosition: Position): this { - if ( - this.caretPosition.index === caretPosition.index && - this.caretPosition.offset === caretPosition.offset - ) { + withSelectedRange(selectedRange: SelectedRange): this { + if (this.selectedRange.isEqual(selectedRange)) { return this; } let result = this.createFieldValue(this.segments); - result.caretPosition = caretPosition; + result.selectedRange = selectedRange; result.previous = this.previous; result.next = this.next; result.isCoalescing = this.isCoalescing; return result; } + withCaretPosition(position: Position): this { + return this.withSelectedRange(new SelectedRange(position)); + } + private splitSegment( segment: TokenFieldSegment | undefined, offset: number @@ -174,14 +233,14 @@ export class TokenFieldValue { appendSegments(newSegments, this.segments.slice(end.index + 1)); let segments = this.createFieldValue(newSegments); - segments.caretPosition = caret; + segments.selectedRange = new SelectedRange(caret); segments.isCoalescing = coalesce; if (this.isCoalescing && coalesce && this.previous) { segments.previous = this.previous; segments.previous.next = segments; } else { segments.previous = this; - this.caretPosition = end; + this.selectedRange = new SelectedRange(start, end); this.next = segments; } return segments; @@ -304,8 +363,7 @@ export class TokenFieldValue { ); } - this.caretPosition = position; - return this; + return this.withSelectedRange(new SelectedRange(position)); } /** Delete text to the next or previous line break. */ @@ -324,7 +382,7 @@ export class TokenFieldValue { ); } - return this; + return this.withSelectedRange(new SelectedRange(position)); } /** Create a new list containing a subset of the segments. */ diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index d35139ef412..c302f5df80a 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -136,8 +136,11 @@ function iconWrapperPlugin(): Plugin { name: 'icon-wrapper', enforce: 'pre', resolveId(source) { - if (source.startsWith('@react-spectrum/s2/icons/')) { - const iconName = source.replace('@react-spectrum/s2/icons/', ''); + // Match both the bare specifier and the form produced after the + // `@react-spectrum/s2` -> exports alias rewrites it to `.../exports/icons/`. + const match = source.match(/(?:@react-spectrum\/s2|[\\/]exports)[\\/]icons[\\/](.+)$/); + if (match) { + const iconName = match[1]; if (iconMap.has(iconName)) { return VIRTUAL_PREFIX + iconName; } From 3543f21b6dafc53452980aa411b7f78bba465607 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 13 Aug 2026 00:06:14 +0000 Subject: [PATCH 09/10] chore: generalise reading values from meta tags (#10433) --- packages/react-aria/src/utils/getMetaValue.ts | 49 ++++++++++++++++ packages/react-aria/src/utils/getNonce.ts | 33 ++--------- .../test/utils/getMetaValue.test.js | 57 +++++++++++++++++++ 3 files changed, 112 insertions(+), 27 deletions(-) create mode 100644 packages/react-aria/src/utils/getMetaValue.ts create mode 100644 packages/react-aria/test/utils/getMetaValue.test.js diff --git a/packages/react-aria/src/utils/getMetaValue.ts b/packages/react-aria/src/utils/getMetaValue.ts new file mode 100644 index 00000000000..54115f79ab6 --- /dev/null +++ b/packages/react-aria/src/utils/getMetaValue.ts @@ -0,0 +1,49 @@ +/* + * 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 {getOwnerDocument, getOwnerWindow} from './domHelpers'; + +declare global { + interface Window { + __webpack_nonce__?: string; + } + var __webpack_nonce__: string | undefined; +} + +export function getMetaValue(key: string, doc?: Document): string | undefined { + let ownerWindow = getOwnerWindow(doc); + let ownerDocument = getOwnerDocument(doc); + + if (ownerDocument == null || ownerWindow == null) { + return; + } + + let content: string | undefined = undefined; + let selector = `meta[name="${CSS.escape(key)}"], meta[property="${CSS.escape(key)}"]`; + let meta = ownerDocument.querySelector(selector); + + if (meta && meta instanceof ownerWindow.HTMLMetaElement) { + if (key === 'csp-nonce' && meta.nonce) { + content ??= meta.nonce || undefined; + } + + if (meta.content) { + content ??= meta.content || undefined; + } + } + + if (key === 'csp-nonce') { + content ??= ownerWindow.__webpack_nonce__ || globalThis.__webpack_nonce__ || undefined; + } + + return content; +} diff --git a/packages/react-aria/src/utils/getNonce.ts b/packages/react-aria/src/utils/getNonce.ts index 54ff2a0d08e..b03216cfe67 100644 --- a/packages/react-aria/src/utils/getNonce.ts +++ b/packages/react-aria/src/utils/getNonce.ts @@ -10,17 +10,8 @@ * governing permissions and limitations under the License. */ -import {getOwnerWindow} from './domHelpers'; - -type NonceWindow = Window & - typeof globalThis & { - __webpack_nonce__?: string; - }; - -function getWebpackNonce(doc?: Document): string | undefined { - let ownerWindow = doc?.defaultView as NonceWindow | null | undefined; - return ownerWindow?.__webpack_nonce__ || globalThis['__webpack_nonce__'] || undefined; -} +import {getMetaValue} from './getMetaValue'; +import {getOwnerDocument} from './domHelpers'; let nonceCache = new WeakMap(); @@ -35,25 +26,13 @@ export function resetNonceCache(): void { * Security Policy. */ export function getNonce(doc?: Document): string | undefined { - let d = doc ?? (typeof document !== 'undefined' ? document : undefined); - if (!d) { - return getWebpackNonce(d); - } - - if (nonceCache.has(d)) { - return nonceCache.get(d); - } + let ownerDocument = getOwnerDocument(doc); - let meta = d.querySelector('meta[property="csp-nonce"]'); - let nonce = - (meta && - meta instanceof getOwnerWindow(meta).HTMLMetaElement && - (meta.nonce || meta.content)) || - getWebpackNonce(d) || - undefined; + let nonce = nonceCache.get(ownerDocument); + nonce ??= getMetaValue('csp-nonce', ownerDocument); if (nonce !== undefined) { - nonceCache.set(d, nonce); + nonceCache.set(ownerDocument, nonce); } return nonce; } diff --git a/packages/react-aria/test/utils/getMetaValue.test.js b/packages/react-aria/test/utils/getMetaValue.test.js new file mode 100644 index 00000000000..5bd292215d2 --- /dev/null +++ b/packages/react-aria/test/utils/getMetaValue.test.js @@ -0,0 +1,57 @@ +/* + * 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 {getMetaValue} from '../../src/utils/getMetaValue'; + +describe('getMetaValue', () => { + afterEach(() => { + document.querySelectorAll('meta').forEach(el => el.remove()); + delete globalThis['__webpack_nonce__']; + }); + + it('returns undefined when no matching meta tag exists', () => { + expect(getMetaValue('theme-color')).toBeUndefined(); + }); + + it('reads a value from the name attribute for an arbitrary key', () => { + let meta = document.createElement('meta'); + meta.setAttribute('name', 'theme-color'); + meta.setAttribute('content', '#ff0000'); + document.head.appendChild(meta); + + expect(getMetaValue('theme-color')).toBe('#ff0000'); + }); + + it('reads a value from the property attribute for an arbitrary key', () => { + let meta = document.createElement('meta'); + meta.setAttribute('property', 'og:title'); + meta.setAttribute('content', 'Hello'); + document.head.appendChild(meta); + + expect(getMetaValue('og:title')).toBe('Hello'); + }); + + it('does not fall back to __webpack_nonce__ for non-nonce keys', () => { + globalThis['__webpack_nonce__'] = 'webpack-nonce'; + + expect(getMetaValue('theme-color')).toBeUndefined(); + }); + + it('escapes special characters in the key when building the selector', () => { + let meta = document.createElement('meta'); + meta.setAttribute('name', 'my:weird.key'); + meta.setAttribute('content', 'escaped'); + document.head.appendChild(meta); + + expect(getMetaValue('my:weird.key')).toBe('escaped'); + }); +}); From 6748e03ce110a15e8270fde5a1f798c361bb3d86 Mon Sep 17 00:00:00 2001 From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:40:49 +0000 Subject: [PATCH 10/10] fix: TokenField IME composition broken in Firefox (#10422) * fix: TokenField IME composition broken in Firefox * fix tests * fix lint --------- Co-authored-by: Devon Govett Co-authored-by: Daniel Lu --- .../ai/test/utils/promptFieldTestUtils.tsx | 10 ++++++++-- packages/react-aria/src/tokenfield/useTokenField.ts | 13 ++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx b/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx index 926d308a47b..fd0b7852fcb 100644 --- a/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx +++ b/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx @@ -47,8 +47,11 @@ export const TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; /** - * Jsdom doesn't implement Range.getBoundingClientRect / getClientRects, which the completion - * popover relies on for positioning. Install stubs so the popover can open. Call in beforeAll. + * Install DOM stubs that jsdom is missing but TokenField/the completion popover rely on. Call + * in beforeAll. + * - Range.getBoundingClientRect / getClientRects: the popover uses these for positioning. + * - InputEvent.getTargetRanges: TokenField reads it on beforeinput to find the edited range. + * Returning [] makes it fall back to the current selection (its pre-existing code path). */ export function installRangePolyfill(): void { let proto = Range.prototype as any; @@ -72,6 +75,9 @@ export function installRangePolyfill(): void { [Symbol.iterator]: function* () {} }); } + if (typeof InputEvent !== 'undefined' && !(InputEvent.prototype as any).getTargetRanges) { + (InputEvent.prototype as any).getTargetRanges = () => []; + } } // Completion data, trimmed from PromptField.stories.tsx. diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 3dfdb1eff2e..ee050095218 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -195,11 +195,14 @@ export function useTokenField( stopComposition(); } - let selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return; + let range = e.getTargetRanges()[0]; + if (!range) { + let selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + range = selection.getRangeAt(0); } - let range = selection.getRangeAt(0); let [start, end] = rangeToPositions(ref.current!, range); // https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes @@ -274,7 +277,7 @@ export function useTokenField( case 'deleteContent': case 'deleteByCut': case 'deleteCompositionText': { - if (!range.collapsed) { + if (!range.collapsed && !isSamePosition(start, end)) { apply(tokens => tokens.replaceRange(start, end, '')); break; }