Skip to content

feat: PromptField#10191

Merged
devongovett merged 48 commits into
mainfrom
tokenfield
Jun 13, 2026
Merged

feat: PromptField#10191
devongovett merged 48 commits into
mainfrom
tokenfield

Conversation

@devongovett

@devongovett devongovett commented Jun 12, 2026

Copy link
Copy Markdown
Member

Implements a PromptField component for the S2 AI package, supporting text with inline tokens, attachments with dnd / paste support, /commands and @reference autocomplete, plus menu, etc. Eventually the lower level TokenField will be used for other use cases as well.

Changes in other packages:

  • Menu onAction now provides both key and value
  • Popover now supports a getTargetRect prop to allow customizing the position to be relative to a character position or arbitrary rectangle
  • S2 Popover exposes isNonModal
  • S2 Menu exposes dependencies
  • Autocomplete handles cases where the collection is not mounted right away better
  • DragTypes#has supports multiple mime types, as well as wildcards (e.g. image/*)

Many changes still to come. This is an initial version for internal testing.

@rspbot

rspbot commented Jun 12, 2026

Copy link
Copy Markdown


let ref = useObjectRef(forwardedRef);
let [state, setState] = useControlledState(valueProp, defaultValueProp, onChange);
let graphemeSegmenter = useMemo(() => new Intl.Segmenter('en-US', {granularity: 'grapheme'}), []);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Todo: use correct locale

@rspbot

rspbot commented Jun 12, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jun 12, 2026

Copy link
Copy Markdown

reidbarber
reidbarber previously approved these changes Jun 12, 2026

@reidbarber reidbarber left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a few comments, but we can get this in for testing.


return (
<Autocomplete>
<TokenField

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we increase the "hit area" for focusing the field? I think it should be a bit more generous since there isn't a border. We could even make it to where clicking anywhere in the PromptField that isn't already interactive focuses the field itself.

);
};

export const Basic = () => (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good to add a maxHeight story since I assume most of these will have one when used.

</PromptField>
);

export const AsyncCompletions = () => (

@reidbarber reidbarber Jun 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we render the Popover in a loading state while the completions are loading? Otherwise it might be confusing UX if the response is slow.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We render the old results while the new ones are loading in ComboBox too. I think it would also be weird if the entire list flickered in and out swapping between a spinner and the menu as you typed each character. But perhaps we can find somewhere to show a loading state in addition to the old list, or some other skeleton-like way of showing the results are outdated...

},
transform: 'translate(-50%, -50%)'
})}>
<ProgressCircle aria-label="Uploading" value={props.uploadProgress} size="S" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs translations.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah we'll need it for all the ai components, post release.

onAction={() => {
let input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use acceptedAttachmentTypes?

}

props.onSubmit?.(prompt, attachments);
setPrompt(new AutoLinkingSegmentList([]));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should attachments be cleared too?

aria-label="Attachments"
onRemove={keys => {
let removedAttachments = attachments.filter(attachment => keys.has(attachment.id));
onRemoveAttachments?.(removedAttachments);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the input get focused after removing the last attachment?

@rspbot

rspbot commented Jun 12, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jun 12, 2026

Copy link
Copy Markdown
## API Changes

react-aria-components

/react-aria-components:Menu

 Menu <T> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children?: ReactNode | (T) => ReactNode
   className?: ClassNameOrFunction<MenuRenderProps> = 'react-aria-Menu'
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   id?: string
   items?: Iterable<T>
-  onAction?: (Key) => void
+  onAction?: (Key, T) => void
   onClose?: () => void
   onSelectionChange?: (Selection) => void
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, MenuRenderProps>
   renderEmptyState?: () => ReactNode
   selectionMode?: SelectionMode
   shouldCloseOnSelect?: boolean
   shouldFocusWrap?: boolean
   slot?: string | null
   style?: StyleOrFunction<MenuRenderProps>
 }

/react-aria-components:Popover

 Popover {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   arrowBoundaryOffset?: number = 0
   arrowRef?: RefObject<Element | null>
   boundaryElement?: Element = document.body
   children?: ChildrenOrFunction<PopoverRenderProps>
   className?: ClassNameOrFunction<PopoverRenderProps> = 'react-aria-Popover'
   containerPadding?: number = 12
   crossOffset?: number = 0
   defaultOpen?: boolean
+  getTargetRect?: (Element) => DOMRect | null | undefined = target.getBoundingClientRect()
   isEntering?: boolean
   isExiting?: boolean
   isKeyboardDismissDisabled?: boolean = false
   isNonModal?: boolean
   maxHeight?: number
   offset?: number = 8
   onOpenChange?: (boolean) => void
   placement?: Placement = 'bottom'
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, PopoverRenderProps>
   scrollRef?: RefObject<Element | null> = overlayRef
   shouldCloseOnInteractOutside?: (Element) => boolean
   shouldFlip?: boolean = true
   shouldUpdatePosition?: boolean = true
   slot?: string | null
   style?: StyleOrFunction<PopoverRenderProps>
   trigger?: string
   triggerRef?: RefObject<Element | null>
 }

/react-aria-components:MenuProps

 MenuProps <T> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children?: ReactNode | (T) => ReactNode
   className?: ClassNameOrFunction<MenuRenderProps> = 'react-aria-Menu'
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   id?: string
   items?: Iterable<T>
-  onAction?: (Key) => void
+  onAction?: (Key, T) => void
   onClose?: () => void
   onSelectionChange?: (Selection) => void
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, MenuRenderProps>
   renderEmptyState?: () => ReactNode
   selectionMode?: SelectionMode
   shouldCloseOnSelect?: boolean
   shouldFocusWrap?: boolean
   slot?: string | null
   style?: StyleOrFunction<MenuRenderProps>
 }

/react-aria-components:PopoverProps

 PopoverProps {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   arrowBoundaryOffset?: number = 0
   arrowRef?: RefObject<Element | null>
   boundaryElement?: Element = document.body
   children?: ChildrenOrFunction<PopoverRenderProps>
   className?: ClassNameOrFunction<PopoverRenderProps> = 'react-aria-Popover'
   containerPadding?: number = 12
   crossOffset?: number = 0
   defaultOpen?: boolean
+  getTargetRect?: (Element) => DOMRect | null | undefined = target.getBoundingClientRect()
   isEntering?: boolean
   isExiting?: boolean
   isKeyboardDismissDisabled?: boolean = false
   isNonModal?: boolean
   maxHeight?: number
   offset?: number = 8
   onOpenChange?: (boolean) => void
   placement?: Placement = 'bottom'
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, PopoverRenderProps>
   scrollRef?: RefObject<Element | null> = overlayRef
   shouldCloseOnInteractOutside?: (Element) => boolean
   shouldFlip?: boolean = true
   shouldUpdatePosition?: boolean = true
   slot?: string | null
   style?: StyleOrFunction<PopoverRenderProps>
   trigger?: string
   triggerRef?: RefObject<Element | null>
 }

/react-aria-components:DragTypes

 DragTypes {
-  has: (string | symbol) => boolean
+  has: (DragType | Array<DragType>) => boolean
 }

@react-aria/dnd

/@react-aria/dnd:DragTypes

 DragTypes {
-  has: (string | symbol) => boolean
+  has: (DragType | Array<DragType>) => boolean
 }

@react-aria/menu

/@react-aria/menu:MenuProps

 MenuProps <T> {
   autoFocus?: boolean | FocusStrategy
   children: CollectionChildren<T>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   items?: Iterable<T>
-  onAction?: (Key) => void
+  onAction?: (Key, T) => void
   onClose?: () => void
   onSelectionChange?: (Selection) => void
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
 }

/@react-aria/menu:AriaMenuProps

 AriaMenuProps <T> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children: CollectionChildren<T>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   id?: string
   items?: Iterable<T>
-  onAction?: (Key) => void
+  onAction?: (Key, T) => void
   onClose?: () => void
   onSelectionChange?: (Selection) => void
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
 }

/@react-aria/menu:AriaMenuOptions

 AriaMenuOptions <T> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   defaultSelectedKeys?: 'all' | Iterable<Key>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   id?: string
   isVirtualized?: boolean
   items?: Iterable<T>
   keyboardDelegate?: KeyboardDelegate
-  onAction?: (Key) => void
+  onAction?: (Key, T) => void
   onClose?: () => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onSelectionChange?: (Selection) => void
   selectionMode?: SelectionMode
   shouldFocusWrap?: boolean
   shouldUseVirtualFocus?: boolean
 }

@react-aria/overlays

/@react-aria/overlays:AriaPositionProps

 AriaPositionProps {
   arrowBoundaryOffset?: number = 0
   arrowRef?: RefObject<Element | null>
   arrowSize?: number = 0
   boundaryElement?: Element = document.body
   containerPadding?: number = 12
   crossOffset?: number = 0
+  getTargetRect?: (Element) => DOMRect | null | undefined = target.getBoundingClientRect()
   isOpen?: boolean
   maxHeight?: number
   offset?: number = 0
   onClose?: () => void | null
   placement?: Placement = 'bottom'
   scrollRef?: RefObject<Element | null> = overlayRef
   shouldFlip?: boolean = true
   shouldUpdatePosition?: boolean = true
   targetRef: RefObject<Element | null>
 }

/@react-aria/overlays:AriaPopoverProps

 AriaPopoverProps {
   arrowBoundaryOffset?: number = 0
   arrowRef?: RefObject<Element | null>
   arrowSize?: number = 0
   boundaryElement?: Element = document.body
   containerPadding?: number = 12
   crossOffset?: number = 0
+  getTargetRect?: (Element) => DOMRect | null | undefined = target.getBoundingClientRect()
   groupRef?: RefObject<Element | null>
   isKeyboardDismissDisabled?: boolean = false
   isNonModal?: boolean
   maxHeight?: number
   placement?: Placement = 'bottom'
   popoverRef: RefObject<Element | null>
   scrollRef?: RefObject<Element | null> = overlayRef
   shouldCloseOnInteractOutside?: (Element) => boolean
   shouldFlip?: boolean = true
   shouldUpdatePosition?: boolean = true
   triggerRef: RefObject<Element | null>
 }

@react-spectrum/ai

/@react-spectrum/ai:Attachment

 Attachment {
-
+  UNSAFE_className?: UnsafeClassName
+  UNSAFE_style?: CSSProperties
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children: ReactNode | (CardRenderProps) => ReactNode
+  density?: 'compact' | 'regular' | 'spacious' = 'regular'
+  download?: boolean | string
+  href?: Href
+  hrefLang?: string
+  id?: Key
+  isDisabled?: boolean
+  onAction?: () => void
+  onPress?: (PressEvent) => void
+  onPressChange?: (boolean) => void
+  onPressEnd?: (PressEvent) => void
+  onPressStart?: (PressEvent) => void
+  onPressUp?: (PressEvent) => void
+  ping?: string
+  referrerPolicy?: HTMLAttributeReferrerPolicy
+  rel?: string
+  routerOptions?: RouterOptions
+  size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
+  styles?: StylesProp
+  target?: HTMLAttributeAnchorTarget
+  textValue?: string
+  uploadProgress?: number
+  value?: T
+  variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' = 'primary'
 }

/@react-spectrum/ai:AttachmentList

-AttachmentList {
+AttachmentList <T> {
-
+  UNSAFE_className?: UnsafeClassName
+  UNSAFE_style?: CSSProperties
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode | (T) => ReactNode
+  className?: string = 'react-aria-TagGroup'
+  defaultSelectedKeys?: 'all' | Iterable<Key>
+  dependencies?: ReadonlyArray<any>
+  disabledKeys?: Iterable<Key>
+  disallowEmptySelection?: boolean
+  escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
+  id?: string
+  items?: Iterable<T>
+  onAction?: (Key) => void
+  onRemove?: (Set<Key>) => void
+  onSelectionChange?: (Selection) => void
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
+  selectedKeys?: 'all' | Iterable<Key>
+  selectionBehavior?: SelectionBehavior = 'toggle'
+  selectionMode?: SelectionMode
+  shouldSelectOnPressUp?: boolean
+  slot?: string | null
+  style?: CSSProperties
+  styles?: StylesProp
 }

@react-spectrum/dnd

/@react-spectrum/dnd:DragTypes

 DragTypes {
-  has: (string | symbol) => boolean
+  has: (DragType | Array<DragType>) => boolean
 }

@react-spectrum/menu

/@react-spectrum/menu:Menu

 Menu <T extends {}> {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   bottom?: Responsive<DimensionValue>
   children: CollectionChildren<{}>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   end?: Responsive<DimensionValue>
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   flex?: Responsive<string | number | boolean>
   flexBasis?: Responsive<number | string>
   flexGrow?: Responsive<number>
   flexShrink?: Responsive<number>
   gridArea?: Responsive<string>
   gridColumn?: Responsive<string>
   gridColumnEnd?: Responsive<string>
   gridColumnStart?: Responsive<string>
   gridRow?: Responsive<string>
   gridRowEnd?: Responsive<string>
   gridRowStart?: Responsive<string>
   height?: Responsive<DimensionValue>
   id?: string
   isHidden?: Responsive<boolean>
   items?: Iterable<{}>
   justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
   left?: Responsive<DimensionValue>
   margin?: Responsive<DimensionValue>
   marginBottom?: Responsive<DimensionValue>
   marginEnd?: Responsive<DimensionValue>
   marginStart?: Responsive<DimensionValue>
   marginTop?: Responsive<DimensionValue>
   marginX?: Responsive<DimensionValue>
   marginY?: Responsive<DimensionValue>
   maxHeight?: Responsive<DimensionValue>
   maxWidth?: Responsive<DimensionValue>
   minHeight?: Responsive<DimensionValue>
   minWidth?: Responsive<DimensionValue>
-  onAction?: (Key) => void
+  onAction?: (Key, {}) => void
   onClose?: () => void
   onSelectionChange?: (Selection) => void
   order?: Responsive<number>
   position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   shouldFocusWrap?: boolean
   start?: Responsive<DimensionValue>
   top?: Responsive<DimensionValue>
   width?: Responsive<DimensionValue>
   zIndex?: Responsive<number>
 }

/@react-spectrum/menu:SpectrumMenuProps

 SpectrumMenuProps <T> {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   bottom?: Responsive<DimensionValue>
   children: CollectionChildren<T>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   end?: Responsive<DimensionValue>
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   flex?: Responsive<string | number | boolean>
   flexBasis?: Responsive<number | string>
   flexGrow?: Responsive<number>
   flexShrink?: Responsive<number>
   gridArea?: Responsive<string>
   gridColumn?: Responsive<string>
   gridColumnEnd?: Responsive<string>
   gridColumnStart?: Responsive<string>
   gridRow?: Responsive<string>
   gridRowEnd?: Responsive<string>
   gridRowStart?: Responsive<string>
   height?: Responsive<DimensionValue>
   id?: string
   isHidden?: Responsive<boolean>
   items?: Iterable<T>
   justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
   left?: Responsive<DimensionValue>
   margin?: Responsive<DimensionValue>
   marginBottom?: Responsive<DimensionValue>
   marginEnd?: Responsive<DimensionValue>
   marginStart?: Responsive<DimensionValue>
   marginTop?: Responsive<DimensionValue>
   marginX?: Responsive<DimensionValue>
   marginY?: Responsive<DimensionValue>
   maxHeight?: Responsive<DimensionValue>
   maxWidth?: Responsive<DimensionValue>
   minHeight?: Responsive<DimensionValue>
   minWidth?: Responsive<DimensionValue>
-  onAction?: (Key) => void
+  onAction?: (Key, T) => void
   onClose?: () => void
   onSelectionChange?: (Selection) => void
   order?: Responsive<number>
   position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   shouldFocusWrap?: boolean
   start?: Responsive<DimensionValue>
   top?: Responsive<DimensionValue>
   width?: Responsive<DimensionValue>
   zIndex?: Responsive<number>
 }

@react-spectrum/overlays

/@react-spectrum/overlays:Popover

 Popover {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
   arrowBoundaryOffset?: number = 0
   arrowRef?: RefObject<Element | null>
   arrowSize?: number = 0
   bottom?: Responsive<DimensionValue>
   boundaryElement?: Element = document.body
   children: ReactNode
   container?: HTMLElement
   containerPadding?: number = 12
   crossOffset?: number = 0
   disableFocusManagement?: boolean
   enableBothDismissButtons?: boolean
   end?: Responsive<DimensionValue>
   flex?: Responsive<string | number | boolean>
   flexBasis?: Responsive<number | string>
   flexGrow?: Responsive<number>
   flexShrink?: Responsive<number>
+  getTargetRect?: (Element) => DOMRect | null | undefined = target.getBoundingClientRect()
   gridArea?: Responsive<string>
   gridColumn?: Responsive<string>
   gridColumnEnd?: Responsive<string>
   gridColumnStart?: Responsive<string>
   gridRowEnd?: Responsive<string>
   gridRowStart?: Responsive<string>
   groupRef?: RefObject<Element | null>
   height?: Responsive<DimensionValue>
   hideArrow?: boolean
   isDisabled?: boolean
   isHidden?: Responsive<boolean>
   isKeyboardDismissDisabled?: boolean = false
   isNonModal?: boolean
   justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
   left?: Responsive<DimensionValue>
   margin?: Responsive<DimensionValue>
   marginBottom?: Responsive<DimensionValue>
   marginEnd?: Responsive<DimensionValue>
   marginStart?: Responsive<DimensionValue>
   marginTop?: Responsive<DimensionValue>
   marginX?: Responsive<DimensionValue>
   marginY?: Responsive<DimensionValue>
   maxHeight?: Responsive<DimensionValue>
   maxWidth?: Responsive<DimensionValue>
   minHeight?: Responsive<DimensionValue>
   minWidth?: Responsive<DimensionValue>
   offset?: number = 0
   onBlurWithin?: (FocusEvent) => void
   onDismissButtonPress?: () => void
   onEnter?: () => void
   onEntered?: () => void
   onEntering?: () => void
   onExit?: () => void
   onExited?: () => void
   onExiting?: () => void
   onFocusWithin?: (FocusEvent) => void
   onFocusWithinChange?: (boolean) => void
   order?: Responsive<number>
   placement?: Placement = 'bottom'
   position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
   right?: Responsive<DimensionValue>
   scrollRef?: RefObject<Element | null> = overlayRef
   shouldCloseOnInteractOutside?: (Element) => boolean
   shouldContainFocus?: boolean
   shouldFlip?: boolean = true
   shouldUpdatePosition?: boolean = true
   start?: Responsive<DimensionValue>
   state: OverlayTriggerState
   top?: Responsive<DimensionValue>
   triggerRef: RefObject<Element | null>
   width?: Responsive<DimensionValue>
   zIndex?: Responsive<number>
 }

@react-spectrum/s2

/@react-spectrum/s2:ActionMenu

 ActionMenu <T> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   align?: 'start' | 'end' = 'start'
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean
   children: ReactNode | (T) => ReactNode
   defaultOpen?: boolean
   direction?: 'bottom' | 'top' | 'left' | 'right' | 'start' | 'end' = 'bottom'
   disabledKeys?: Iterable<Key>
   id?: string
   isDisabled?: boolean
   isOpen?: boolean
   isQuiet?: boolean
   items?: Iterable<T>
   menuSize?: 'S' | 'M' | 'L' | 'XL' = 'M'
-  onAction?: (Key) => void
+  onAction?: (Key, T) => void
   onOpenChange?: (boolean) => void
   shouldCloseOnSelect?: boolean
   shouldFlip?: boolean = true
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
 }

/@react-spectrum/s2:ContextualHelpPopover

 ContextualHelpPopover {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children: ReactNode
   containerPadding?: number = 12
   crossOffset?: number = 0
+  getTargetRect?: (Element) => DOMRect | null | undefined = target.getBoundingClientRect()
   hideArrow?: boolean = false
   id?: string
+  isNonModal?: boolean
   isOpen?: boolean
   offset?: number = 8
   onOpenChange?: (boolean) => void
   padding?: 'default' | 'none' = 'default'
   role?: 'dialog' | 'alertdialog' = 'dialog'
   shouldFlip?: boolean = true
   size?: 'S' | 'M' | 'L'
   slot?: string | null
   styles?: PopoverStylesProp
   triggerRef?: RefObject<Element | null>
 }

/@react-spectrum/s2:Menu

 Menu <T> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children: ReactNode | (T) => ReactNode
   defaultSelectedKeys?: 'all' | Iterable<Key>
+  dependencies?: ReadonlyArray<any>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   hideLinkOutIcon?: boolean
   id?: string
   items?: Iterable<T>
-  onAction?: (Key) => void
+  onAction?: (Key, T) => void
   onClose?: () => void
   onSelectionChange?: (Selection) => void
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   shouldFocusWrap?: boolean
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   styles?: StylesProp
 }

/@react-spectrum/s2:Popover

 Popover {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode
   containerPadding?: number = 12
   crossOffset?: number = 0
+  getTargetRect?: (Element) => DOMRect | null | undefined = target.getBoundingClientRect()
   hideArrow?: boolean = false
   id?: string
+  isNonModal?: boolean
   isOpen?: boolean
   offset?: number = 8
   onOpenChange?: (boolean) => void
   padding?: 'default' | 'none' = 'default'
   role?: 'dialog' | 'alertdialog' = 'dialog'
   shouldFlip?: boolean = true
   size?: 'S' | 'M' | 'L'
   slot?: string | null
   styles?: PopoverStylesProp
   triggerRef?: RefObject<Element | null>
 }

/@react-spectrum/s2:ActionMenuProps

 ActionMenuProps <T> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   align?: 'start' | 'end' = 'start'
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean
   children: ReactNode | (T) => ReactNode
   defaultOpen?: boolean
   direction?: 'bottom' | 'top' | 'left' | 'right' | 'start' | 'end' = 'bottom'
   disabledKeys?: Iterable<Key>
   id?: string
   isDisabled?: boolean
   isOpen?: boolean
   isQuiet?: boolean
   items?: Iterable<T>
   menuSize?: 'S' | 'M' | 'L' | 'XL' = 'M'
-  onAction?: (Key) => void
+  onAction?: (Key, T) => void
   onOpenChange?: (boolean) => void
   shouldCloseOnSelect?: boolean
   shouldFlip?: boolean = true
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
 }

/@react-spectrum/s2:ContextualHelpPopoverProps

 ContextualHelpPopoverProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children: ReactNode
   containerPadding?: number = 12
   crossOffset?: number = 0
+  getTargetRect?: (Element) => DOMRect | null | undefined = target.getBoundingClientRect()
   hideArrow?: boolean = false
   id?: string
+  isNonModal?: boolean
   isOpen?: boolean
   offset?: number = 8
   onOpenChange?: (boolean) => void
   padding?: 'default' | 'none' = 'default'
   role?: 'dialog' | 'alertdialog' = 'dialog'
   shouldFlip?: boolean = true
   size?: 'S' | 'M' | 'L'
   slot?: string | null
   styles?: PopoverStylesProp
   triggerRef?: RefObject<Element | null>
 }

/@react-spectrum/s2:MenuProps

 MenuProps <T> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children: ReactNode | (T) => ReactNode
   defaultSelectedKeys?: 'all' | Iterable<Key>
+  dependencies?: ReadonlyArray<any>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   hideLinkOutIcon?: boolean
   id?: string
   items?: Iterable<T>
-  onAction?: (Key) => void
+  onAction?: (Key, T) => void
   onClose?: () => void
   onSelectionChange?: (Selection) => void
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   shouldFocusWrap?: boolean
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   styles?: StylesProp
 }

/@react-spectrum/s2:PopoverProps

 PopoverProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   arrowRef?: RefObject<Element | null>
   boundaryElement?: Element = document.body
   children?: ChildrenOrFunction<PopoverRenderProps>
   containerPadding?: number = 12
   crossOffset?: number = 0
   defaultOpen?: boolean
+  getTargetRect?: (Element) => DOMRect | null | undefined = target.getBoundingClientRect()
   hideArrow?: boolean = false
   isEntering?: boolean
   isExiting?: boolean
+  isNonModal?: boolean
   isOpen?: boolean
   maxHeight?: number
   offset?: number = 8
   onOpenChange?: (boolean) => void
   scrollRef?: RefObject<Element | null> = overlayRef
   shouldFlip?: boolean = true
   size?: 'S' | 'M' | 'L'
   slot?: string | null
   styles?: StyleString
   trigger?: string
   triggerRef?: RefObject<Element | null>
 }

/@react-spectrum/s2:DragTypes

 DragTypes {
-  has: (string | symbol) => boolean
+  has: (DragType | Array<DragType>) => boolean
 }

@rspbot

rspbot commented Jun 12, 2026

Copy link
Copy Markdown

Agent Skills Changes

Modified (2)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/3656c88fe879463839b451eeb43b1ccfadfaa110/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/3656c88fe879463839b451eeb43b1ccfadfaa110/

@LFDanLu LFDanLu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approving for testing, parital review

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

early days for the AttachmentList so I sure either this hasn't been implemented/designed yet but might be nice to have a collapsed list for the attachments since it is a TagGroup.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh I guess that can actually be set since PromptFieldAttachmentList allows that prop?

isFocused?: boolean;
}

function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would we consider somehow allowing users to customize some aspects of this (max width, flipping, etc)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if a usecase comes up, sure

Comment on lines +607 to +610
// TODO: do we want to export these?
export function setCursor(root: Element, pos: Position, fireEvent = false) {
setSelection(root, pos, pos, fireEvent);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you imagining providing them as handy utils for people building their own TokenField?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah possibly. I used them in the tests, and at least one of them for positioning the popover relative to the cursor position

<PromptField onSubmit={handleSend} isGenerating={isPending}>
<div className={style({display: 'flex', gap: 16, alignItems: 'center'})}>
<PromptTokenField />
<PromptFieldSubmitButton />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor since it is in the story styles but noticed the button is vertically centered when you add more lines to the field

);
};

function ComboBoxTagInput() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might just be the logic in here but I noticed that deleting tokens via backspace didn't actually remove them from the selectedItems list

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ya the combobox example is incomplete for now

@devongovett devongovett added this pull request to the merge queue Jun 13, 2026
Merged via the queue into main with commit 5740128 Jun 13, 2026
30 checks passed
@devongovett devongovett deleted the tokenfield branch June 13, 2026 00:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants