Skip to content

feat: Add TokenField to react-aria-components#10318

Open
devongovett wants to merge 12 commits into
mainfrom
tokenfield-rac
Open

feat: Add TokenField to react-aria-components#10318
devongovett wants to merge 12 commits into
mainfrom
tokenfield-rac

Conversation

@devongovett

Copy link
Copy Markdown
Member

This adds a new TokenField component to react-aria-components. A token field allows users to enter text with inline tokens. Use it to build AI prompt fields, tag inputs, structured search fields, mention inputs, and multi-select comboboxes.

Changes:

  • Moved from S2 AI package into RAC.
  • Refactored implementation into react-aria and react-stately hooks.
  • Added documentation and starter examples.
  • Updated API to match other field components to support visible label and description elements: TokenField wraps Label, TokenInput, and Text.
  • Fixed re-tokenizing pasted text/tokens

import {Key} from '@react-types/shared';
import {TokenSegmentList} from 'react-aria-components/TokenField';

export class ComboBoxSegmentList extends TokenSegmentList<Key> {

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.

Do we want to maintain some of these subclasses shown in the docs as official implementations for any of these patterns? Or just rely on copy/paste for now?

@rspbot

rspbot commented Jul 9, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 9, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jul 9, 2026

Copy link
Copy Markdown
## API Changes

react-aria-components

/react-aria-components:TokenField

+TokenField <T extends TokenSegmentList = TokenSegmentList> {
+  allowsNewlines?: boolean
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  autoFocus?: boolean
+  children?: ChildrenOrFunction<TokenFieldRenderProps>
+  className?: ClassNameOrFunction<TokenFieldRenderProps> = 'react-aria-TokenField'
+  defaultValue?: TokenSegmentList
+  isDisabled?: boolean
+  isReadOnly?: boolean
+  onBlur?: (FocusEvent<Target>) => void
+  onChange?: (TokenSegmentList) => void
+  onCopy?: ClipboardEventHandler<TokenSegmentList>
+  onCut?: ClipboardEventHandler<TokenSegmentList>
+  onFocus?: (FocusEvent<Target>) => void
+  onFocusChange?: (boolean) => void
+  onKeyDown?: (KeyboardEvent) => void
+  onKeyUp?: (KeyboardEvent) => void
+  onPaste?: ClipboardEventHandler<TokenSegmentList>
+  onSubmit?: () => void
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TokenFieldRenderProps>
+  role?: 'textbox' | 'searchbox' | 'combobox' = 'textbox'
+  slot?: string | null
+  style?: StyleOrFunction<TokenFieldRenderProps>
+  value?: TokenSegmentList
+}

/react-aria-components:TokenInput

+TokenInput <T extends TokenSegmentList = TokenSegmentList> {
+  children: (TokenSegment<TokenSegmentList extends TokenSegmentList<any> ? V : never>) => React.ReactElement
+  className?: ClassNameOrFunction<TokenInputRenderProps> = 'react-aria-TokenInput'
+  isDisabled?: boolean
+  onHoverChange?: (boolean) => void
+  onHoverEnd?: (HoverEvent) => void
+  onHoverStart?: (HoverEvent) => void
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TokenInputRenderProps>
+  slot?: string | null
+  style?: StyleOrFunction<TokenInputRenderProps>
+}

/react-aria-components:Token

+Token {
+  children?: ChildrenOrFunction<TokenRenderProps>
+  className?: ClassNameOrFunction<TokenRenderProps> = 'react-aria-Token'
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TokenRenderProps>
+  style?: StyleOrFunction<TokenRenderProps>
+}

/react-aria-components:TokenFieldContext

+TokenFieldContext {
+  UNTYPED
+}

/react-aria-components:TokenSegmentList

+TokenSegmentList <T = any> {
+  caretPosition: Position
+  constructor: (readonly Array<TokenFieldSegment<T>>, TokenSegmentListOptions) => void
+  delete: (Position, Intl.Segmenter, any, any) => this
+  deleteLine: (Position, any, any) => this
+  endCoalescing: () => void
+  findBoundaryWithSegmenter: (Position, Intl.Segmenter, any) => Position | null
+  findLineBoundary: (Position, any) => Position | null
+  findText: (Position, any, string | RegExp) => Position | null
+  redo: () => this
+  replaceRange: (Position, Position, string, any) => this
+  replaceRangeWithSegments: (Position, Position, Array<TokenFieldSegment<T>>, any) => this
+  segments: readonly Array<TokenFieldSegment<T>>
+  slice: (Position, Position) => this
+  toString: () => string
+  undo: () => this
+  withCaretPosition: (Position) => this
+}

/react-aria-components:TokenFieldProps

+TokenFieldProps <T extends TokenSegmentList = TokenSegmentList> {
+  allowsNewlines?: boolean
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  autoFocus?: boolean
+  children?: ChildrenOrFunction<TokenFieldRenderProps>
+  className?: ClassNameOrFunction<TokenFieldRenderProps> = 'react-aria-TokenField'
+  defaultValue?: TokenSegmentList
+  isDisabled?: boolean
+  isReadOnly?: boolean
+  onBlur?: (FocusEvent<Target>) => void
+  onChange?: (TokenSegmentList) => void
+  onCopy?: ClipboardEventHandler<TokenSegmentList>
+  onCut?: ClipboardEventHandler<TokenSegmentList>
+  onFocus?: (FocusEvent<Target>) => void
+  onFocusChange?: (boolean) => void
+  onKeyDown?: (KeyboardEvent) => void
+  onKeyUp?: (KeyboardEvent) => void
+  onPaste?: ClipboardEventHandler<TokenSegmentList>
+  onSubmit?: () => void
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TokenFieldRenderProps>
+  role?: 'textbox' | 'searchbox' | 'combobox' = 'textbox'
+  slot?: string | null
+  style?: StyleOrFunction<TokenFieldRenderProps>
+  value?: TokenSegmentList
+}

/react-aria-components:TokenFieldRenderProps

+TokenFieldRenderProps {
+  isDisabled: boolean
+  isReadOnly: boolean
+}

/react-aria-components:TokenInputProps

+TokenInputProps <T extends TokenSegmentList = TokenSegmentList> {
+  children: (TokenSegment<TokenSegmentList extends TokenSegmentList<any> ? V : never>) => React.ReactElement
+  className?: ClassNameOrFunction<TokenInputRenderProps> = 'react-aria-TokenInput'
+  isDisabled?: boolean
+  onHoverChange?: (boolean) => void
+  onHoverEnd?: (HoverEvent) => void
+  onHoverStart?: (HoverEvent) => void
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TokenInputRenderProps>
+  slot?: string | null
+  style?: StyleOrFunction<TokenInputRenderProps>
+}

/react-aria-components:TokenInputRenderProps

+TokenInputRenderProps {
+  isDisabled: boolean
+  isFocusVisible: boolean
+  isFocused: boolean
+  isHovered: boolean
+  isReadOnly: boolean
+}

/react-aria-components:TokenProps

+TokenProps {
+  children?: ChildrenOrFunction<TokenRenderProps>
+  className?: ClassNameOrFunction<TokenRenderProps> = 'react-aria-Token'
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TokenRenderProps>
+  style?: StyleOrFunction<TokenRenderProps>
+}

/react-aria-components:TokenRenderProps

+TokenRenderProps {
+  isDisabled: boolean
+  isSelected: boolean
+}

@react-aria/combobox

/@react-aria/combobox:AriaComboBoxOptions

 AriaComboBoxOptions <M extends SelectionMode = 'single', T> {
   allowsCustomValue?: boolean
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean
   buttonRef?: RefObject<Element | null>
   defaultInputValue?: string
   defaultItems?: Iterable<T>
   defaultValue?: ValueType<SelectionMode>
   description?: ReactNode
   disabledKeys?: Iterable<Key>
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   form?: string
   id?: string
   inputRef: RefObject<HTMLInputElement | null>
   inputValue?: string
   isDisabled?: boolean
   isInvalid?: boolean
   isReadOnly?: boolean
   isRequired?: boolean
   items?: Iterable<T>
   keyboardDelegate?: KeyboardDelegate
   label?: ReactNode
+  labelElementType?: ElementType = 'label'
   layoutDelegate?: LayoutDelegate
   listBoxRef: RefObject<HTMLElement | null>
   menuTrigger?: MenuTriggerAction = 'input'
   name?: string
   onChange?: (ChangeValueType<SelectionMode>) => void
   onFocus?: (FocusEvent<HTMLInputElement>) => void
   onFocusChange?: (boolean) => void
   onInputChange?: (string) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onOpenChange?: (boolean, MenuTriggerAction) => void
   placeholder?: string
   popoverRef: RefObject<Element | null>
   selectionMode?: SelectionMode = 'single'
   shouldFocusWrap?: boolean
   validate?: (ComboBoxValidationValue<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'aria' | 'native' = 'aria'
   value?: ValueType<SelectionMode>
 }

@react-spectrum/ai

/@react-spectrum/ai:PromptToken

 PromptToken {
   children: React.ReactNode
-  className?: ClassNameOrFunction<TokenRenderProps>
+  className?: ClassNameOrFunction<TokenRenderProps> = 'react-aria-Token'
   style?: StyleOrFunction<TokenRenderProps>
 }

/@react-spectrum/ai:PromptTokenProps

 PromptTokenProps {
   children: React.ReactNode
-  className?: ClassNameOrFunction<TokenRenderProps>
+  className?: ClassNameOrFunction<TokenRenderProps> = 'react-aria-Token'
   style?: StyleOrFunction<TokenRenderProps>
 }

@rspbot

rspbot commented Jul 9, 2026

Copy link
Copy Markdown

Agent Skills Changes

Added (2)
Modified (11)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/ffebffbfbc0df77977827ced0b3d95398d6b4e9a/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/ffebffbfbc0df77977827ced0b3d95398d6b4e9a/

@snowystinger snowystinger 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.

will continue reviewing tomorrow

Comment thread packages/@react-spectrum/ai/src/PromptField.tsx
Comment thread packages/react-aria-components/src/TokenField.tsx
('htmlFor' in labelProps && labelProps.htmlFor) || props.isDisabled
? undefined
: () => {
inputRef.current?.focus();

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.

Suggested change
inputRef.current?.focus();
inputRef.current?.focus({focusVisible: true});

Looks like this is pretty close to widely available. Should the interaction modality and visible really be true though? should we grab the current modality?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@snowystinger On that topic, what's the target range you guys require? Is it strictly widely available, or is newly-available fine for new features?

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.

why now? we don't do this anywhere else.

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.

why now?

it was on my mind from another Issue I was responding to. I'm ok leaving it alone

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.

Is it strictly widely available

We transpile to widely available, if that's what you're asking? Otherwise, we look at fallbacks, polyfills, or just matching an API (Temporal).

@nwidynski nwidynski Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yeah, it was more so directed at wheter a missing polyfill for a newly-available API (e.g. Navigation API) would be a blocker for new features requiring them. Thats of course if no other alternative for backwards compatibility can be found.

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