From 57a1c82c87100e80ebefe8f79626bea5d8499302 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 17 Jul 2026 18:12:26 -0700 Subject: [PATCH] feat: Add TokenField to react-aria-components (#10318) * refactor TokenField into hooks * Add docs * Tokenize when pasting multiple segments * docs updates * multiline -> allowsNewlines * support visible label and description * lint * Render combobox label as span when not an input element * lint * add starter stories * move isComposing state into stately * fix docs issues * swap to useKeyboard shortcuts and add macOS specific shortcuts * rename TokenSegmentList to TokenFieldValue * fix formatting --- packages/@react-spectrum/ai/exports/index.ts | 4 +- packages/@react-spectrum/ai/intl/en-US.json | 1 + .../@react-spectrum/ai/src/PromptField.tsx | 83 +-- .../ai/stories/Chat.stories.tsx | 6 +- .../s2-docs/pages/react-aria/Autocomplete.mdx | 6 + .../dev/s2-docs/pages/react-aria/ComboBox.mdx | 5 + .../react-aria/ComboBoxTokenFieldValue.ts | 59 +++ .../s2-docs/pages/react-aria/TagFieldValue.ts | 23 + .../s2-docs/pages/react-aria/TokenField.mdx | 481 ++++++++++++++++++ .../pages/react-aria/TokenizingFieldValue.ts | 50 ++ .../exports/TokenField.ts | 31 ++ .../react-aria-components/exports/index.ts | 10 + .../react-aria-components/src/ComboBox.tsx | 16 +- .../react-aria-components/src/TokenField.tsx | 384 ++++++++++++++ .../stories/TokenField.stories.tsx | 82 ++- .../stories/styles.global.css | 6 +- .../test/TokenField.browser.test.tsx | 102 +++- .../TokenField.composition.browser.test.tsx | 22 +- .../test/TokenField.test.js | 74 +++ .../test/utils/tokenFieldBrowserUtils.tsx | 47 +- packages/react-aria/exports/useTokenField.ts | 16 + .../react-aria/src/combobox/useComboBox.ts | 20 +- .../react-aria/src/tokenfield/useToken.ts | 56 ++ .../src/tokenfield/useTokenField.ts} | 411 ++++++--------- .../exports/useTokenFieldState.ts | 23 + .../src/tokenfield/TokenFieldValue.ts} | 61 +-- .../src/tokenfield/useTokenFieldState.ts | 51 ++ .../test/tokenfield/TokenFieldValue.test.ts} | 172 ++++--- starters/docs/src/Form.css | 1 + starters/docs/src/TokenField.css | 65 +++ starters/docs/src/TokenField.tsx | 52 ++ starters/docs/stories/TokenField.stories.tsx | 145 ++++++ starters/tailwind/src/TokenField.tsx | 80 +++ .../tailwind/stories/TokenField.stories.tsx | 142 ++++++ 34 files changed, 2299 insertions(+), 488 deletions(-) create mode 100644 packages/dev/s2-docs/pages/react-aria/ComboBoxTokenFieldValue.ts create mode 100644 packages/dev/s2-docs/pages/react-aria/TagFieldValue.ts create mode 100644 packages/dev/s2-docs/pages/react-aria/TokenField.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/TokenizingFieldValue.ts create mode 100644 packages/react-aria-components/exports/TokenField.ts create mode 100644 packages/react-aria-components/src/TokenField.tsx rename packages/{@react-spectrum/ai => react-aria-components}/stories/TokenField.stories.tsx (85%) rename packages/{@react-spectrum/ai => react-aria-components}/stories/styles.global.css (86%) rename packages/{@react-spectrum/ai => react-aria-components}/test/TokenField.browser.test.tsx (92%) rename packages/{@react-spectrum/ai => react-aria-components}/test/TokenField.composition.browser.test.tsx (95%) create mode 100644 packages/react-aria-components/test/TokenField.test.js rename packages/{@react-spectrum/ai => react-aria-components}/test/utils/tokenFieldBrowserUtils.tsx (85%) create mode 100644 packages/react-aria/exports/useTokenField.ts create mode 100644 packages/react-aria/src/tokenfield/useToken.ts rename packages/{@react-spectrum/ai/src/TokenField.tsx => react-aria/src/tokenfield/useTokenField.ts} (70%) create mode 100644 packages/react-stately/exports/useTokenFieldState.ts rename packages/{@react-spectrum/ai/src/TokenSegmentList.ts => react-stately/src/tokenfield/TokenFieldValue.ts} (88%) create mode 100644 packages/react-stately/src/tokenfield/useTokenFieldState.ts rename packages/{@react-spectrum/ai/test/TokenSegmentList.test.ts => react-stately/test/tokenfield/TokenFieldValue.test.ts} (87%) create mode 100644 starters/docs/src/TokenField.css create mode 100644 starters/docs/src/TokenField.tsx create mode 100644 starters/docs/stories/TokenField.stories.tsx create mode 100644 starters/tailwind/src/TokenField.tsx create mode 100644 starters/tailwind/stories/TokenField.stories.tsx diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 5e837d9bf92..a9d8a39c46c 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -15,7 +15,7 @@ export { } from '../src/PromptField'; export {ResponseStatus, ResponseStatusTitle, ResponseStatusPanel} from '../src/ResponseStatus'; export {Chat, Thread, ThreadItem, ThreadScrollButton} from '../src/Chat'; -export {TokenSegmentList} from '../src/TokenSegmentList'; +export {TokenFieldValue} from 'react-aria-components/TokenField'; export {UserMessage} from '../src/UserMessage'; export type {AttachmentProps, AttachmentListProps} from '../src/AttachmentList'; @@ -39,5 +39,5 @@ export type { ResponseStatusPanelProps } from '../src/ResponseStatus'; export type {ChatProps, ThreadProps, ThreadItemProps, ThreadScrollButtonProps} from '../src/Chat'; -export type {TokenSegmentListOptions} from '../src/TokenSegmentList'; +export type {TokenFieldValueOptions} from 'react-aria-components/TokenField'; export type {UserMessageProps} from '../src/UserMessage'; diff --git a/packages/@react-spectrum/ai/intl/en-US.json b/packages/@react-spectrum/ai/intl/en-US.json index 555ea77c69a..6584405209f 100644 --- a/packages/@react-spectrum/ai/intl/en-US.json +++ b/packages/@react-spectrum/ai/intl/en-US.json @@ -3,5 +3,6 @@ "messagefeedback.thumbDown": "Bad response", "messagefeedback.thumbUp": "Good response", "responsestatus.loading": "Loading", + "promptfield.label": "Prompt", "promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task." } diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index e18d031b5be..192f5fa6fa1 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -14,16 +14,17 @@ 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 {Button} from '@react-spectrum/s2/Button'; -import {Cell} from './loader/data'; -import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; 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 { createContext, createRef, @@ -38,9 +39,9 @@ import { Direction, Position, TokenFieldSegment, - TokenSegment, - TokenSegmentList -} from './TokenSegmentList'; + TokenFieldValue, + TokenSegment +} from 'react-stately/useTokenFieldState'; import {IconContext} from '@react-spectrum/s2'; import {Image, Text} from '@react-spectrum/s2/Card'; // @ts-ignore @@ -51,7 +52,13 @@ import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Men import {PixelLoader} from './loader/react'; import Plus from '@react-spectrum/s2/icons/Add'; import {Popover, PopoverProps} from '@react-spectrum/s2/Popover'; -import {positionToDOMRange, Token, TokenField, TokenProps} from './TokenField'; +import { + positionToDOMRange, + Token, + TokenField, + TokenInput, + TokenProps +} from 'react-aria-components/TokenField'; import {PromptFieldContainer} from './PromptFieldContainer'; import {PromptFocusContext} from './Chat'; import Send from '@react-spectrum/s2/icons/ArrowUpSend'; @@ -68,7 +75,7 @@ export interface PromptFieldAttachment { export interface PromptFieldProps { children: React.ReactNode; acceptedAttachmentTypes?: string[]; - onSubmit?: (prompt: TokenSegmentList, attachments: PromptFieldAttachment[]) => void; + onSubmit?: (prompt: TokenFieldValue, attachments: PromptFieldAttachment[]) => void; isGenerating?: boolean; onStop?: () => void; onAddAttachments?: (attachments: PromptFieldAttachment[]) => void; @@ -81,8 +88,8 @@ interface PromptFieldState { attachments: PromptFieldAttachment[]; setAttachments: React.Dispatch>; acceptedAttachmentTypes?: string[]; - prompt: TokenSegmentList; - setPrompt: React.Dispatch>; + prompt: TokenFieldValue; + setPrompt: React.Dispatch>; inputRef: React.RefObject; onSubmit?: () => void; onStop?: () => void; @@ -93,7 +100,7 @@ interface PromptFieldState { // TODO: make this customizable const tokenRegex = /(?<=\s|^)(https?:\/\/)?(www\.)?([^/\s]+\.[a-z]{2,}(\/\S+)?)(?=\s)/g; -class AutoLinkingSegmentList extends TokenSegmentList { +class AutoLinkingTokenFieldValue extends TokenFieldValue { tokenize(text: string): TokenFieldSegment[] { if (text.length === 0) { return [{type: 'text', text}]; @@ -123,7 +130,7 @@ class AutoLinkingSegmentList extends TokenSegmentList { const PromptFieldContext = createContext({ attachments: [], setAttachments: () => {}, - prompt: new AutoLinkingSegmentList([]), + prompt: new AutoLinkingTokenFieldValue([]), setPrompt: () => {}, inputRef: createRef(), isGenerating: false @@ -152,7 +159,7 @@ export function PromptField(props: PromptFieldProps) { onRemoveAttachments, variant = 'balanced' } = props; - let [prompt, setPrompt] = useState(new AutoLinkingSegmentList([])); + let [prompt, setPrompt] = useState(new AutoLinkingTokenFieldValue([])); let [attachments, setAttachments] = useState([]); // Not using RAC DropZone because it adds its own focusable button, @@ -190,7 +197,7 @@ export function PromptField(props: PromptFieldProps) { } props.onSubmit?.(prompt, attachments); - setPrompt(new AutoLinkingSegmentList([])); + setPrompt(new AutoLinkingTokenFieldValue([])); setAttachments([]); inputRef.current?.focus(); }; @@ -335,10 +342,10 @@ export function PromptTokenField(props: PromptTokenFieldProps) { { if (e.isTrusted) { setFocused(true); @@ -370,26 +377,28 @@ export function PromptTokenField(props: PromptTokenFieldProps) { } } : undefined - } - onSubmit={onSubmit} - className={ - css('&:empty::before { content: attr(data-placeholder); }') + - style({ - flexGrow: 1, - font: 'body', - color: { - default: 'gray-800', - ':empty': { - default: 'transparent-overlay-600', - forcedColors: 'GrayText' - } - }, - width: 'full', - outlineStyle: 'none', - cursor: 'text' - }) }> - {children || (segment => {segment.text})} + + css('&:empty::before { content: attr(data-placeholder); }') + + style({ + font: 'body', + color: { + default: baseColor('neutral'), + ':empty': { + default: 'gray-600', + forcedColors: 'GrayText' + } + }, + width: 'full', + outlineStyle: 'none', + cursor: 'text' + })(renderProps) + }> + {children || (segment => {segment.text})} + ([]); - function handleSend(prompt: TokenSegmentList) { + function handleSend(prompt: TokenFieldValue) { setGenerating(true); // user message added first so its announcement plays before setMessages(prev => [ @@ -566,7 +566,7 @@ export function VirtualizedChat() { let nextId = useRef(initialResponses.length); let lastMessage = messages.at(-1); let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending'; - function handleSend(prompt: TokenSegmentList) { + function handleSend(prompt: TokenFieldValue) { setMessages(prev => [ ...prev, {id: nextId.current++, type: 'user', content: prompt.toString()}, diff --git a/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx b/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx index 290e3affd82..a94b155c666 100644 --- a/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx @@ -4,6 +4,7 @@ export default Layout; import docs from 'docs:react-aria-components'; import vanillaDocs from 'docs:vanilla-starter/CommandPalette'; import '../../tailwind/tailwind.css'; +import {InlineAlert, Heading, Content} from '@react-spectrum/s2' export const tags = ['combobox', 'typeahead', 'input']; export const version = 'rc'; @@ -110,6 +111,11 @@ export const description = 'Allows users to search or filter a list of suggestio + + Autocomplete vs. ComboBox + Use [ComboBox](ComboBox) to **select one or more values** from a pre-defined set of options. Use Autocomplete to **filter a collection** or provide **text completions**. + + ## Content Autocomplete filters a collection component using a [TextField](TextField) or [SearchField](SearchField). It can be used to build UI patterns such as command palettes, searchable menus, filterable selects, and more. diff --git a/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx b/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx index e35671538e1..dfbf7d391e5 100644 --- a/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx +++ b/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx @@ -50,6 +50,11 @@ export const description = 'Combines a text input with a listbox, allowing users + + Autocomplete vs. ComboBox + Use ComboBox to **select one or more values** from a pre-defined set of options. Use [Autocomplete](Autocomplete) to **filter a collection** or provide **text completions**. + + ## Content `ComboBox` reuses the `ListBox` component, following the [Collection Components API](collections?component=ComboBox). It supports ListBox features such as static and dynamic collections, sections, disabled items, links, text slots, asynchronous loading, etc. See the [ListBox docs](ListBox) for more details. diff --git a/packages/dev/s2-docs/pages/react-aria/ComboBoxTokenFieldValue.ts b/packages/dev/s2-docs/pages/react-aria/ComboBoxTokenFieldValue.ts new file mode 100644 index 00000000000..b56ef947412 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/ComboBoxTokenFieldValue.ts @@ -0,0 +1,59 @@ +import {Key} from '@react-types/shared'; +import {TokenFieldValue} from 'react-aria-components/TokenField'; + +export class ComboBoxTokenFieldValue extends TokenFieldValue { + getSelectedKeys(): Key[] { + return this.segments.filter(seg => seg.type === 'token').map(seg => seg.value!); + } + + getInputValue(): string { + let segment = this.segments[this.caretPosition.index]; + return segment?.type === 'text' ? segment.text : ''; + } + + setSelectedKeys(keys: Key[]): ComboBoxTokenFieldValue { + let selectedKeys = this.getSelectedKeys(); + let added: Key[] = []; + for (let key of keys) { + if (!selectedKeys.includes(key)) { + added.push(key); + } + } + let removed = new Set(); + for (let key of selectedKeys) { + if (!keys.includes(key)) { + removed.add(key); + } + } + + let value = this; + for (let key of removed) { + let index = value.segments.findIndex(seg => seg.type === 'token' && seg.value! === key); + value = value.replaceRangeWithSegments( + {index: index, offset: 0}, + {index: index, offset: value.segments[index]?.text.length ?? 0}, + [], + false + ); + } + + if (added.length > 0) { + let segment = value.segments[value.caretPosition.index]; + value = value.replaceRangeWithSegments( + {index: value.caretPosition.index, offset: 0}, + segment?.type === 'text' + ? { + index: value.caretPosition.index, + offset: value.segments[value.caretPosition.index]?.text.length ?? 0 + } + : {index: value.caretPosition.index, offset: 0}, + added.map(value => { + return {type: 'token', text: String(value), value: value}; + }), + false + ); + } + + return value; + } +} diff --git a/packages/dev/s2-docs/pages/react-aria/TagFieldValue.ts b/packages/dev/s2-docs/pages/react-aria/TagFieldValue.ts new file mode 100644 index 00000000000..fd98151fdac --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/TagFieldValue.ts @@ -0,0 +1,23 @@ +import {type TokenFieldSegment, TokenFieldValue} from 'react-aria-components/TokenField'; + +export class TagFieldValue extends TokenFieldValue { + tokenize(text: string): TokenFieldSegment[] { + let parts = text.split(/[, \n]/); + + let segments: TokenFieldSegment[] = parts.map((part, i) => { + if (i === parts.length - 1 || part.length === 0) { + return {type: 'text', text: part}; + } + return {type: 'token', text: part}; + }); + + if (parts.at(-1)?.length === 0) { + segments.pop(); + } + return segments; + } + + toString(): string { + return this.segments.map(seg => seg.text).join(', '); + } +} diff --git a/packages/dev/s2-docs/pages/react-aria/TokenField.mdx b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx new file mode 100644 index 00000000000..aa0869e7c29 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx @@ -0,0 +1,481 @@ +import {Layout} from '../../src/Layout'; +export default Layout; + +import docs from 'docs:react-aria-components'; +import vanillaDocs from 'docs:vanilla-starter/TokenField'; +import '../../tailwind/tailwind.css'; + +export const tags = ['input', 'token', 'mention', 'tag', 'autocomplete', 'ai', 'prompt', 'comment', 'multi-select', 'combobox', 'search']; +export const version = 'alpha'; +export const description = 'Allows users to enter text with inline tokens such as mentions, tags, or object references.'; + +# TokenField + +{docs.exports.TokenField.description} + + + ```tsx render docs={vanillaDocs.exports.TokenField} links={vanillaDocs.links} props={['label', 'description', 'allowsNewlines', 'isDisabled']} initialProps={{label: 'Message', allowsNewlines: true}} type="vanilla" files={["starters/docs/src/TokenField.tsx", "starters/docs/src/TokenField.css", "packages/dev/s2-docs/pages/react-aria/TokenizingFieldValue.ts"]} + "use client"; + import {Token, TokenField} from 'vanilla-starter/TokenField'; + import {TokenizingFieldValue} from './TokenizingFieldValue'; + + + {segment => {segment.text}} + + ``` + + ```tsx render docs={vanillaDocs.exports.TokenField} links={vanillaDocs.links} props={['label', 'description', 'allowsNewlines', 'isDisabled']} initialProps={{label: 'Message', allowsNewlines: true}} type="tailwind" files={["starters/docs/src/TokenField.tsx", "starters/docs/src/TokenField.css", "packages/dev/s2-docs/pages/react-aria/TokenizingFieldValue.ts"]} + "use client"; + import {Token, TokenField} from 'tailwind-starter/TokenField'; + import {TokenizingFieldValue} from './TokenizingFieldValue'; + + + {segment => {segment.text}} + + ``` + + +## Value + +`TokenField` is controlled via a `TokenFieldValue` value, which represents a sequence of text and token segments. Use the `value` or `defaultValue` prop to set the value, and `onChange` to handle user input. + +```tsx render +"use client"; +import {Token, TokenField} from 'vanilla-starter/TokenField'; +import {TokenFieldValue} from 'react-stately/useTokenFieldState'; +import {useState} from 'react'; + +function Example() { + let [value, setValue] = useState( + new TokenFieldValue([ + {type: 'text', text: 'Hello '}, + {type: 'token', text: '@username'}, + {type: 'text', text: '!'} + ]) + ); + + return ( + <> + {/*- begin highlight -*/} + + {segment => {segment.text}} + + {/*- end highlight -*/} +

Value: {value.toString()}

+ + ); +} +``` + +## Tokenization + +Extend the `TokenFieldValue` class to customize how text is converted into tokens. Override the `tokenize` method to parse user input, and use `createFieldValue` to return new instances of your subclass when the value changes. + +```tsx render +"use client"; +import {Token, TokenField} from 'vanilla-starter/TokenField'; +import {TokenizingFieldValue} from './TokenizingFieldValue'; + + + {segment => {segment.text}} + +``` + +## Autocomplete + +Combine `TokenField` with [Autocomplete](Autocomplete) to provide inline completions such as @mentions and slash commands. Use `TokenFieldValue.findText` to locate the anchor character, and `positionToDOMRange` to position a [Popover](Popover) relative to the filter text. + +```tsx render +"use client"; +import {Autocomplete} from 'react-aria-components/Autocomplete'; +import {Text} from 'react-aria-components/Text'; +import {Token, TokenField} from 'vanilla-starter/TokenField'; +import {positionToDOMRange} from 'react-aria/useTokenField'; +import {Direction, TokenFieldValue} from 'react-aria-components/TokenField'; +import {Menu, MenuItem} from 'vanilla-starter/Menu'; +import {Popover} from 'vanilla-starter/Popover'; +import {useMemo, useRef, useState} from 'react'; + +type Item = {username: string} | {command: string; description: string}; + +/*- begin collapse -*/ +const usernames = [ + {username: 'alexmiller'}, + {username: 'sarahjones'}, + {username: 'davidkim'}, + {username: 'emmawatson'}, + {username: 'oliverliu'}, + {username: 'ellagreen'}, + {username: 'lucasbrown'}, + {username: 'amandarivera'}, + {username: 'masonlee'}, + {username: 'nataliasmith'}, + {username: 'benjamintaylor'}, + {username: 'zoewilson'}, + {username: 'henrywalker'}, + {username: 'madelineyoung'}, + {username: 'noahscott'}, + {username: 'lucygonzalez'}, + {username: 'jacobmartin'}, + {username: 'averymoore'}, + {username: 'loganmurphy'}, + {username: 'miahernandez'}, + {username: 'danieladair'}, + {username: 'sofiacox'}, + {username: 'jackharris'}, + {username: 'chloebaker'}, + {username: 'liamrodriguez'} +]; +/*- end collapse -*/ + +/*- begin collapse -*/ +const slashCommands = [ + {command: 'gif', description: 'Insert a GIF'}, + {command: 'todo', description: 'Add a todo list item'}, + {command: 'mention', description: 'Mention a user with @username'}, + {command: 'date', description: 'Insert the current date'}, + {command: 'quote', description: 'Insert a quote block'} +]; +/*- end collapse -*/ + +function Example() { + let inputRef = useRef(null); + let [value, setValue] = useState( + new TokenFieldValue([ + {type: 'text', text: 'This example has autocomplete for '}, + {type: 'token', text: '@usernames'}, + {type: 'text', text: ' and '}, + {type: 'token', text: '/commands'} + ]) + ); + + let [filterAnchor, filterValue] = useMemo(() => { + let filterAnchor = value.findText(value.caretPosition, Direction.Backward, /(?<=^|\s)[@/]/); + if (filterAnchor != null) { + let filterValue = value.slice(filterAnchor, value.caretPosition).toString(); + return [filterAnchor, filterValue]; + } + return [null, null]; + }, [value]); + + let items: Item[] = []; + if (filterValue != null && filterValue.startsWith('/')) { + items = slashCommands.filter(item => item.command.includes(filterValue.slice(1))); + } else if (filterValue != null && filterValue.startsWith('@')) { + items = usernames.filter(item => item.username.includes(filterValue.slice(1))); + } + + return ( + /*- begin highlight -*/ + + + {segment => {segment.text}} + + 0} + isNonModal + hideArrow + placement="bottom start" + trigger="MenuTrigger" + getTargetRect={target => { + return positionToDOMRange(target, filterAnchor!).getBoundingClientRect(); + }}> + + {item => ( + { + setValue(value => + value.replaceRangeWithSegments( + filterAnchor!, + value.caretPosition, + [ + { + type: 'token', + text: 'username' in item ? '@' + item.username : item.command + }, + {type: 'text', text: ' '} + ], + false + ) + ); + }}> + {'username' in item ? item.username : item.command} + {'description' in item ? {item.description} : null} + + )} + + + + /*- end highlight -*/ + ); +} +``` + +## Tag field + +Use a custom `TokenFieldValue` to build a tag input. This example converts comma, space, or newline separated text into tokens. + +```tsx render files={["packages/dev/s2-docs/pages/react-aria/TagFieldValue.ts"]} +"use client"; +import {Token, TokenField} from 'vanilla-starter/TokenField'; +import {TagFieldValue} from './TagFieldValue'; + + + {segment => {segment.text}} + +``` + +## Search + +Token fields can be used to build advanced search UIs with structured query tokens. This example suggests filters based on the current text segment. + +```tsx render +"use client"; +import {Autocomplete} from 'react-aria-components/Autocomplete'; +import {Collection} from 'react-aria-components/Collection'; +import {Token, TokenField} from 'vanilla-starter/TokenField'; +import {Header, Menu, MenuItem, MenuSection} from 'vanilla-starter/Menu'; +import {Popover} from 'vanilla-starter/Popover'; +import {useRef, useState} from 'react'; +import {TokenFieldValue} from 'react-stately/useTokenFieldState'; + +/*- begin collapse -*/ +const usernames = [ + {username: 'alexmiller'}, + {username: 'sarahjones'}, + {username: 'davidkim'}, + {username: 'emmawatson'}, + {username: 'oliverliu'}, + {username: 'ellagreen'}, + {username: 'lucasbrown'}, + {username: 'amandarivera'}, + {username: 'masonlee'}, + {username: 'nataliasmith'}, + {username: 'benjamintaylor'}, + {username: 'zoewilson'}, + {username: 'henrywalker'}, + {username: 'madelineyoung'}, + {username: 'noahscott'}, + {username: 'lucygonzalez'}, + {username: 'jacobmartin'}, + {username: 'averymoore'}, + {username: 'loganmurphy'}, + {username: 'miahernandez'}, + {username: 'danieladair'}, + {username: 'sofiacox'}, + {username: 'jackharris'}, + {username: 'chloebaker'}, + {username: 'liamrodriguez'} +]; +/*- end collapse -*/ + +function Example() { + let inputRef = useRef(null); + let [value, setValue] = useState( + new TokenFieldValue([{type: 'token', text: 'From: Alice Smith'}]) + ); + + let last = value.segments[value.caretPosition.index]; + let filterText = last?.type === 'text' ? last.text : null; + let suggestions: {name: string; items: string[]}[] = []; + if (filterText != null) { + let users = usernames + .filter(item => item.username.includes(filterText)) + .map(u => u.username) + .slice(0, 5); + if (users.length > 0) { + if ( + !value.segments.some( + segment => segment.type === 'token' && segment.text.startsWith('From: ') + ) + ) { + suggestions.push({ + name: 'From', + items: users + }); + } + + suggestions.push({ + name: 'To', + items: users + }); + } + + suggestions.push({ + name: 'Subject', + items: [filterText] + }); + } + + return ( + + + {segment => {segment.text}} + + 0} + isNonModal + hideArrow + placement="bottom start" + style={{width: 'var(--trigger-width)'}} + trigger="MenuTrigger"> + + {section => ( + +
{section.name}
+ + {item => ( + { + setValue(value => + value.replaceRangeWithSegments( + {index: value.caretPosition.index, offset: 0}, + value.caretPosition, + [{type: 'token', text: section.name + ': ' + item}], + false + ) + ); + }}> + {item} + + )} + +
+ )} +
+
+
+ ); +} +``` + +## ComboBox + +Use `TokenField` as the input for a [ComboBox](ComboBox) to build a multi-select field with tokens. Synchronize the `TokenFieldValue` with the ComboBox `value` and `inputValue` props using a custom segment list class. + +```tsx render files={["packages/dev/s2-docs/pages/react-aria/ComboBoxTokenFieldValue.ts"]} +"use client"; +import {ComboBox} from 'react-aria-components/ComboBox'; +import {Token, TokenField} from 'vanilla-starter/TokenField'; +import {ComboBoxItem, ComboBoxListBox} from 'vanilla-starter/ComboBox'; +import {FieldButton, Label} from 'vanilla-starter/Form'; +import {Popover} from 'vanilla-starter/Popover'; +import {ComboBoxTokenFieldValue} from './ComboBoxTokenFieldValue'; +import {ChevronDown} from 'lucide-react'; +import {useState} from 'react'; + +/*- begin collapse -*/ +const usernames = [ + {username: 'alexmiller'}, + {username: 'sarahjones'}, + {username: 'davidkim'}, + {username: 'emmawatson'}, + {username: 'oliverliu'}, + {username: 'ellagreen'}, + {username: 'lucasbrown'}, + {username: 'amandarivera'}, + {username: 'masonlee'}, + {username: 'nataliasmith'}, + {username: 'benjamintaylor'}, + {username: 'zoewilson'}, + {username: 'henrywalker'}, + {username: 'madelineyoung'}, + {username: 'noahscott'}, + {username: 'lucygonzalez'}, + {username: 'jacobmartin'}, + {username: 'averymoore'}, + {username: 'loganmurphy'}, + {username: 'miahernandez'}, + {username: 'danieladair'}, + {username: 'sofiacox'}, + {username: 'jackharris'}, + {username: 'chloebaker'}, + {username: 'liamrodriguez'} +]; +/*- end collapse -*/ + +function Example() { + let [value, setValue] = useState(new ComboBoxTokenFieldValue([])); + + return ( + setValue(value.setSelectedKeys(keys))}> + +
+ + {segment => {segment.text}} + + + + +
+ + + {state => {state.username}} + + +
+ ); +} +``` + +## API + +```tsx links={{TokenField: '#tokenfield', TokenInput: '#token-input', Token: '#token'}} + + +``` + +### TokenField + + + +### TokenInput + + + +### Token + + + +### TokenFieldValue + + diff --git a/packages/dev/s2-docs/pages/react-aria/TokenizingFieldValue.ts b/packages/dev/s2-docs/pages/react-aria/TokenizingFieldValue.ts new file mode 100644 index 00000000000..a549a7a5ec0 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/TokenizingFieldValue.ts @@ -0,0 +1,50 @@ +import {type TokenFieldSegment, TokenFieldValue} from 'react-aria-components/TokenField'; + +export class TokenizingFieldValue extends TokenFieldValue { + tokenRegex: RegExp; + + constructor(tokens: TokenFieldSegment[], tokenRegex: RegExp) { + super(tokens); + this.tokenRegex = tokenRegex; + } + + static tokenize(text: string, tokenRegex: RegExp): TokenFieldValue { + let list = new this([], tokenRegex); + let segments = list.tokenize(text); + return new this(segments, tokenRegex); + } + + createFieldValue(segments: TokenFieldSegment[]): this { + let Constructor = this.constructor as new ( + tokens: TokenFieldSegment[], + tokenRegex: RegExp + ) => this; + return new Constructor(segments, this.tokenRegex); + } + + tokenize(text: string): TokenFieldSegment[] { + if (text.length === 0) { + return [{type: 'text', text}]; + } + + let tokenRegex = this.tokenRegex; + tokenRegex.lastIndex = 0; + + let match: RegExpExecArray | null = null; + let start = 0; + let segments: TokenFieldSegment[] = []; + while ((match = tokenRegex.exec(text))) { + if (match.index > start) { + segments.push({type: 'text', text: text.slice(start, match.index)}); + } + segments.push({type: 'token', text: match[0]}); + start = match.index + match[0].length; + } + + if (start < text.length) { + segments.push({type: 'text', text: text.slice(start)}); + } + + return segments; + } +} diff --git a/packages/react-aria-components/exports/TokenField.ts b/packages/react-aria-components/exports/TokenField.ts new file mode 100644 index 00000000000..5070da538aa --- /dev/null +++ b/packages/react-aria-components/exports/TokenField.ts @@ -0,0 +1,31 @@ +/* + * 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 {TokenField, TokenInput, Token} from '../src/TokenField'; +export type { + TokenFieldProps, + TokenFieldRenderProps, + TokenInputProps, + TokenInputRenderProps, + TokenProps, + TokenRenderProps, + TokenFieldContext +} from '../src/TokenField'; +export {positionToDOMRange} from 'react-aria/useTokenField'; +export {TokenFieldValue, Direction} from 'react-stately/useTokenFieldState'; +export type { + TokenFieldSegment, + TokenSegment, + TextSegment, + Position, + TokenFieldValueOptions +} from 'react-stately/useTokenFieldState'; diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index a03fa5933c3..b30c850f97f 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -225,6 +225,8 @@ export {TagGroup, TagGroupContext, TagList, TagListContext, Tag} from '../src/Ta export {Text, TextContext} from '../src/Text'; export {TextArea, TextAreaContext} from '../src/TextArea'; export {TextField, TextFieldContext} from '../src/TextField'; +export {TokenField, TokenInput, Token, TokenFieldContext} from '../src/TokenField'; +export {TokenFieldValue} from 'react-stately/useTokenFieldState'; export { UNSTABLE_Toast, UNSTABLE_ToastList, @@ -494,6 +496,14 @@ export type { } from '../src/TagGroup'; export type {TextAreaProps} from '../src/TextArea'; export type {TextFieldProps, TextFieldRenderProps} from '../src/TextField'; +export type { + TokenFieldProps, + TokenFieldRenderProps, + TokenInputProps, + TokenInputRenderProps, + TokenProps, + TokenRenderProps +} from '../src/TokenField'; export type {TextProps} from '../src/Text'; export type { ToastRegionProps, diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 14502016b6e..1899cb90ed9 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -26,7 +26,7 @@ import { useSlot, useSlottedContext } from './utils'; -import {Collection, Node} from '@react-types/shared'; +import {Collection, FocusableElement, Node} from '@react-types/shared'; import {CollectionBuilder} from 'react-aria/CollectionBuilder'; import {ComboBoxState, useComboBoxState} from 'react-stately/useComboBoxState'; import {createHideableComponent} from 'react-aria/private/collections/Hidden'; @@ -218,6 +218,8 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: ComboBoxInnerPr let listBoxRef = useRef(null); let popoverRef = useRef(null); let [labelRef, label] = useSlot(!props['aria-label'] && !props['aria-labelledby']); + let [labelElementType, setLabelElementType] = useState<'span' | 'label'>('label'); + let { buttonProps, inputProps, @@ -236,7 +238,8 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: ComboBoxInnerPr listBoxRef, popoverRef, name: formValue === 'text' ? name : undefined, - validationBehavior + validationBehavior, + labelElementType }, state ); @@ -307,14 +310,19 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: ComboBoxInnerPr { + inputRef.current = el as HTMLInputElement; // TODO: figure out how to fix non-input element types in useComboBox/useTextField + if (el) { + setLabelElementType(el.tagName.toLowerCase() === 'input' ? 'label' : 'span'); + } + }, []), value: state.inputValue, onChange: v => state.setInputValue(v as string) } as any diff --git a/packages/react-aria-components/src/TokenField.tsx b/packages/react-aria-components/src/TokenField.tsx new file mode 100644 index 00000000000..ddc96c403e3 --- /dev/null +++ b/packages/react-aria-components/src/TokenField.tsx @@ -0,0 +1,384 @@ +/* + * 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 {AriaTokenFieldProps} from 'react-aria/useTokenField'; +import { + ClassNameOrFunction, + ContextValue, + dom, + Provider, + RenderProps, + SlotProps, + StyleRenderProps, + useContextProps, + useRenderProps, + useSlot, + useSlottedContext +} from './utils'; +import {createHideableComponent} from 'react-aria/private/collections/Hidden'; +import {FieldInputContext} from './Autocomplete'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; +import {HoverProps, useHover} from 'react-aria/useHover'; +import {LabelContext} from './Label'; +import {mergeProps} from 'react-aria/mergeProps'; +import {mergeRefs} from 'react-aria/mergeRefs'; +import React, { + createContext, + ForwardedRef, + forwardRef, + HTMLAttributes, + memo, + RefObject, + useContext, + useMemo +} from 'react'; +import {TextContext} from './Text'; +import { + TokenFieldState, + TokenFieldValue, + TokenSegment, + useTokenFieldState +} from 'react-stately/useTokenFieldState'; +import {useFocusRing} from 'react-aria/useFocusRing'; +import {useObjectRef} from 'react-aria/useObjectRef'; +import {useToken, useTokenField} from 'react-aria/useTokenField'; + +export interface TokenFieldRenderProps { + /** + * Whether the token field is disabled. + * + * @selector [data-disabled] + */ + isDisabled: boolean; + /** + * Whether the token field is read only. + * + * @selector [data-readonly] + */ + isReadOnly: boolean; +} + +export interface TokenFieldProps + extends + AriaTokenFieldProps, + RenderProps, + SlotProps, + GlobalDOMAttributes { + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * element. A function may be provided to compute the class based on component state. + * + * @default 'react-aria-TokenField' + */ + className?: ClassNameOrFunction; +} + +export interface TokenInputRenderProps { + /** + * Whether the token input is currently hovered with a mouse. + * + * @selector [data-hovered] + */ + isHovered: boolean; + /** + * Whether the token input is focused, either via a mouse or keyboard. + * + * @selector [data-focused] + */ + isFocused: boolean; + /** + * Whether the token input is keyboard focused. + * + * @selector [data-focus-visible] + */ + isFocusVisible: boolean; + /** + * Whether the token input is disabled. + * + * @selector [data-disabled] + */ + isDisabled: boolean; + /** + * Whether the token input is read only. + * + * @selector [data-readonly] + */ + isReadOnly: boolean; +} + +export interface TokenInputProps + extends + HoverProps, + StyleRenderProps, + SlotProps, + GlobalDOMAttributes { + /** + * A function that renders a token for each segment in the token field. + */ + children: ( + segment: TokenSegment ? V : never> + ) => React.ReactElement; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * element. A function may be provided to compute the class based on component state. + * + * @default 'react-aria-TokenInput' + */ + className?: ClassNameOrFunction; +} + +interface TokenInputContextValue { + tokenFieldProps: HTMLAttributes; + state: TokenFieldState; + isDisabled: boolean; + isReadOnly: boolean; + autocompleteProps?: HTMLAttributes; + ref: RefObject; +} + +export const TokenFieldContext = createContext>(null); +const TokenInputContext = createContext(null); + +/** + * 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. + */ +export const TokenField = /*#__PURE__*/ createHideableComponent(function TokenField< + T extends TokenFieldValue = TokenFieldValue +>(props: TokenFieldProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, TokenFieldContext); + let [labelRef, label] = useSlot(!props['aria-label'] && !props['aria-labelledby']); + + let fieldCtx = useSlottedContext(FieldInputContext, props.slot); + let { + value: _autocompleteValue, + onChange: onAutocompleteChange, + ref: autocompleteRef, + ...autocompleteProps + } = fieldCtx ?? {}; + let inputRef = useObjectRef(autocompleteRef); + + let isDisabled = props.isDisabled || false; + let isReadOnly = props.isReadOnly || false; + + let state = useTokenFieldState({ + ...props, + onChange: (value: T) => { + props.onChange?.(value); + onAutocompleteChange?.(value.toString()); + } + }); + + let {tokenFieldProps, labelProps, descriptionProps} = useTokenField( + { + ...props, + // @ts-ignore - not a public prop, used to determine if slot is present + label, + role: props.role || autocompleteProps['role'] || 'textbox' + }, + state, + inputRef + ); + + let renderProps = useRenderProps({ + ...props, + values: { + isDisabled, + isReadOnly + }, + defaultClassName: 'react-aria-TokenField' + }); + + let DOMProps = filterDOMProps(props, {global: true}); + + return ( + + , + ref: inputRef as RefObject + } + ] + ]}> + {renderProps.children} + + + ); +}); + +/** + * A token input represents the editable area within a token field. + */ +export const TokenInput = /*#__PURE__*/ (forwardRef as forwardRefType)(function TokenInput< + T extends TokenFieldValue = TokenFieldValue +>(props: TokenInputProps, forwardedRef: ForwardedRef) { + let { + tokenFieldProps, + state, + isDisabled = false, + isReadOnly = false, + autocompleteProps, + ref: contextRef + } = useContext(TokenInputContext)!; + let ref = useMemo(() => mergeRefs(contextRef, forwardedRef), [contextRef, forwardedRef]); + let {children, ...domProps} = props; + + let {isHovered, hoverProps} = useHover(domProps); + let {isFocused, isFocusVisible, focusProps} = useFocusRing(); + + let renderProps = useRenderProps({ + ...domProps, + defaultClassName: 'react-aria-TokenInput', + values: { + isHovered, + isFocused, + isFocusVisible, + isDisabled, + isReadOnly + } + }); + + let DOMProps = filterDOMProps(domProps, {global: true}); + + return ( + + + {state.value.segments.map((v, i) => { + switch (v.type) { + case 'token': { + let token = children(v); + return ( + // Wrap tokens in zero-width spaces so the cursor is placed correctly. + + {'\u200b'} + {token} + {'\u200b'} + + ); + } + case 'text': + return v.text; + } + })} + {/* Force cursor to the next line if the last segment ends with a newline. */} + {state.value.segments.at(-1)?.text.endsWith('\n') &&
} +
+
+ ); +}); + +export interface TokenRenderProps { + /** + * Whether the token is selected. + * + * @selector [data-selected] + */ + isSelected: boolean; + /** + * Whether the token is disabled. + * + * @selector [data-disabled] + */ + isDisabled: boolean; +} + +export interface TokenProps + extends RenderProps, GlobalDOMAttributes { + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * element. A function may be provided to compute the class based on component state. + * + * @default 'react-aria-Token' + */ + className?: ClassNameOrFunction; +} + +/** + * A token represents an inline segment within a token field. + */ +export const Token = forwardRef(function Token( + props: TokenProps, + ref: ForwardedRef +) { + let {isDisabled} = useContext(TokenInputContext)!; + let objectRef = useObjectRef(ref); + let {tokenProps, isSelected} = useToken(props, {}, objectRef); + + let renderProps = useRenderProps({ + ...props, + defaultClassName: 'react-aria-Token', + values: { + isSelected, + isDisabled + } + }); + + let DOMProps = filterDOMProps(props, {global: true}); + + return ( + + {renderProps.children} + + ); +}); + +// Prevents React from re-rendering during composition events. +const CompositionRenderBlocker = memo( + ({children}: {children: React.ReactNode; isComposing: boolean}) => children, + (prevProps, nextProps) => + nextProps.isComposing ? true : prevProps.children === nextProps.children +); diff --git a/packages/@react-spectrum/ai/stories/TokenField.stories.tsx b/packages/react-aria-components/stories/TokenField.stories.tsx similarity index 85% rename from packages/@react-spectrum/ai/stories/TokenField.stories.tsx rename to packages/react-aria-components/stories/TokenField.stories.tsx index 75fa0cebc63..89d207dffdb 100644 --- a/packages/@react-spectrum/ai/stories/TokenField.stories.tsx +++ b/packages/react-aria-components/stories/TokenField.stories.tsx @@ -10,7 +10,6 @@ * governing permissions and limitations under the License. */ -import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; import {Meta, StoryFn} from '@storybook/react'; import React, {useMemo, useRef, useState} from 'react'; import './styles.global.css'; @@ -18,31 +17,25 @@ import {Autocomplete} from 'react-aria-components/Autocomplete'; import {ChevronDown} from 'lucide-react'; import {Collection, ComboBox} from 'react-aria-components'; import {ComboBoxItem, ComboBoxListBox} from 'vanilla-starter/ComboBox'; -import {Direction, type TokenFieldSegment, TokenSegmentList} from '../src/TokenSegmentList'; +import {Direction, type TokenFieldSegment, TokenFieldValue} from 'react-stately/useTokenFieldState'; import {FieldButton, Label} from 'vanilla-starter/Form'; import {Header, Menu, MenuItem, MenuSection} from 'vanilla-starter/Menu'; import {Key} from '@react-types/shared'; import {Popover} from 'vanilla-starter/Popover'; -import {positionToDOMRange, Token, TokenField} from '../src/TokenField'; +import {positionToDOMRange} from 'react-aria/useTokenField'; +import {Token, TokenField, TokenInput} from '../src/TokenField'; import 'vanilla-starter/TagGroup.css'; import {Text} from 'react-aria-components/Text'; -const events = ['onChange', 'onPaste', 'onSubmit', 'onFocus', 'onBlur', 'onFocusChange']; - export default { - title: 'AI/TokenField', + title: 'React Aria Components/TokenField', component: TokenField, - tags: ['autodocs'], - argTypes: { - ...categorizeArgTypes('Events', events), - children: {table: {disable: true}} - }, - args: {...getActionArgs(events)} + tags: ['autodocs'] } as Meta; export type TokenFieldStory = StoryFn; -class TokenizingSegmentList extends TokenSegmentList { +class TokenizingFieldValue extends TokenFieldValue { tokenRegex: RegExp; constructor(tokens: TokenFieldSegment[], tokenRegex: RegExp) { @@ -50,13 +43,13 @@ class TokenizingSegmentList extends TokenSegmentList { this.tokenRegex = tokenRegex; } - static tokenize(text: string, tokenRegex: RegExp): TokenSegmentList { + static tokenize(text: string, tokenRegex: RegExp): TokenFieldValue { let list = new this([], tokenRegex); let segments = list.tokenize(text); return new this(segments, tokenRegex); } - createSegmentList(segments: TokenFieldSegment[]): this { + createFieldValue(segments: TokenFieldSegment[]): this { let Constructor = this.constructor as new ( tokens: TokenFieldSegment[], tokenRegex: RegExp @@ -94,13 +87,14 @@ class TokenizingSegmentList extends TokenSegmentList { export const AutoTokenize: TokenFieldStory = () => { return ( - {segment => {segment.text}} + )}> + + {segment => {segment.text}} + Type #hashtags or @usernames to create tokens. ); }; @@ -108,13 +102,13 @@ export const AutoTokenize: TokenFieldStory = () => { export const Template: TokenFieldStory = () => { return ( - {segment => {segment.text}} + )}> + + {segment => {segment.text}} ); }; @@ -174,7 +168,7 @@ type Item = {username: string} | {command: string; description: string}; export const WithAutocomplete: TokenFieldStory = () => { let inputRef = useRef(null); let [value, setValue] = useState( - new TokenSegmentList([ + new TokenFieldValue([ {type: 'text', text: 'This example has autocomplete for '}, {type: 'token', text: '@usernames'}, {type: 'text', text: ' and '}, @@ -200,8 +194,9 @@ export const WithAutocomplete: TokenFieldStory = () => { return ( - - {segment => {segment.text}} + + + {segment => {segment.text}} { ); }; -class TagFieldSegmentList extends TokenSegmentList { +class TagFieldValue extends TokenFieldValue { tokenize(text: string): TokenFieldSegment[] { let parts = text.split(/[, \n]/); @@ -268,18 +263,18 @@ class TagFieldSegmentList extends TokenSegmentList { export const TagField: TokenFieldStory = () => { return ( - {segment => {segment.text}} + }> + + {segment => {segment.text}} ); }; @@ -287,7 +282,7 @@ export const TagField: TokenFieldStory = () => { export const Search: TokenFieldStory = () => { let inputRef = useRef(null); let [value, setValue] = useState( - new TokenSegmentList([{type: 'token', text: 'From: Alice Smith'}]) + new TokenFieldValue([{type: 'token', text: 'From: Alice Smith'}]) ); let last = value.segments.at(-1); @@ -324,8 +319,9 @@ export const Search: TokenFieldStory = () => { return ( - - {segment => {segment.text}} + + + {segment => {segment.text}} { ); }; -class ComboBoxSegmentList extends TokenSegmentList { +class ComboBoxTokenFieldValue extends TokenFieldValue { getSelectedKeys(): Key[] { return this.segments.filter(seg => seg.type === 'token').map(seg => seg.value!); } @@ -375,7 +371,7 @@ class ComboBoxSegmentList extends TokenSegmentList { return segment?.type === 'text' ? segment.text : ''; } - setSelectedKeys(keys: Key[]): ComboBoxSegmentList { + setSelectedKeys(keys: Key[]): ComboBoxTokenFieldValue { let selectedKeys = this.getSelectedKeys(); let added: Key[] = []; for (let key of keys) { @@ -427,7 +423,7 @@ class ComboBoxSegmentList extends TokenSegmentList { } export const ComboBoxExample: TokenFieldStory = () => { - let [value, setValue] = useState(new ComboBoxSegmentList([])); + let [value, setValue] = useState(new ComboBoxTokenFieldValue([])); return ( { onChange={keys => setValue(value.setSelectedKeys(keys))}>
- - {segment => {segment.text}} + + + {segment => {segment.text}} + diff --git a/packages/@react-spectrum/ai/stories/styles.global.css b/packages/react-aria-components/stories/styles.global.css similarity index 86% rename from packages/@react-spectrum/ai/stories/styles.global.css rename to packages/react-aria-components/stories/styles.global.css index 665027e5685..1c219e67232 100644 --- a/packages/@react-spectrum/ai/stories/styles.global.css +++ b/packages/react-aria-components/stories/styles.global.css @@ -1,7 +1,7 @@ -@import '../../../../starters/docs/src/theme.css'; -@import '../../../../starters/docs/src/utilities.css'; +@import '../../../starters/docs/src/theme.css'; +@import '../../../starters/docs/src/utilities.css'; -.react-aria-TokenField { +.react-aria-TokenInput { padding: 4px 8px; outline: none; border-radius: 8px; diff --git a/packages/@react-spectrum/ai/test/TokenField.browser.test.tsx b/packages/react-aria-components/test/TokenField.browser.test.tsx similarity index 92% rename from packages/@react-spectrum/ai/test/TokenField.browser.test.tsx rename to packages/react-aria-components/test/TokenField.browser.test.tsx index 34a70b33408..062a786665d 100644 --- a/packages/@react-spectrum/ai/test/TokenField.browser.test.tsx +++ b/packages/react-aria-components/test/TokenField.browser.test.tsx @@ -39,12 +39,14 @@ import { wordDeleteModKey, wordNavModKey } from './utils/tokenFieldBrowserUtils'; -import {CLIPBOARD_MIME_TYPE, Token, TokenField} from '../src/TokenField'; import {commands, userEvent} from 'vitest/browser'; import {describe, expect, it} from 'vitest'; import {isFirefox, isWebKit} from 'react-aria/private/utils/platform'; import React from 'react'; import {render} from 'vitest-browser-react'; +import {Token, TokenField, TokenInput} from '../src/TokenField'; + +const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json'; declare module 'vitest/browser' { interface BrowserCommands { @@ -76,7 +78,7 @@ describeOrSkip('TokenField browser interactions', () => { it('should render textbox and tokens', async () => { let screen = await render( - {segment => {segment.text}} + {segment => {segment.text}} ); await expect.element(screen.getByRole('textbox', {name: 'Message'})).toBeInTheDocument(); @@ -112,7 +114,7 @@ describeOrSkip('TokenField browser interactions', () => { onChange={e => setValue(segments(text(e.target.value)))} /> - {segment => {segment.text}} + {segment => {segment.text}} ); @@ -291,6 +293,92 @@ describeOrSkip('TokenField browser interactions', () => { await userEvent.keyboard(`{${mod}>}{ArrowRight}{/${mod}}`); await waitForSelection(textbox, {index: 0, offset: 11}); }); + + describe('macOS Control shortcuts', () => { + it('moves to field start with Control+a on single line', async () => { + if (!isMacPlatform()) { + return; + } + + let list = segments(text('hello')); + let {textbox} = await renderControlledTokenField(list); + await navigateCaret(textbox, list, {index: 0, offset: 5}); + await userEvent.keyboard('{Control>}a{/Control}'); + await waitForSelection(textbox, {index: 0, offset: 0}); + }); + + it('moves to field end with Control+e on single line', async () => { + if (!isMacPlatform()) { + return; + } + + let list = segments(text('hello')); + let {textbox} = await renderControlledTokenField(list); + await navigateCaret(textbox, list, {index: 0, offset: 0}); + await userEvent.keyboard('{Control>}e{/Control}'); + await waitForSelection(textbox, {index: 0, offset: 5}); + }); + + it('moves to previous line boundary with Control+a in multiline field', async () => { + if (!isMacPlatform()) { + return; + } + + let multiline = segments(text('hello\nworld')); + let {textbox} = await renderControlledTokenField(multiline); + await navigateCaret(textbox, multiline, {index: 0, offset: 11}); + await userEvent.keyboard('{Control>}a{/Control}'); + await waitForSelection(textbox, {index: 0, offset: 5}); + }); + + it('moves to end of line with Control+e in multiline field', async () => { + if (!isMacPlatform()) { + return; + } + + let multiline = segments(text('hello\nworld')); + let {textbox} = await renderControlledTokenField(multiline); + await navigateCaret(textbox, multiline, {index: 0, offset: 6}); + await userEvent.keyboard('{Control>}e{/Control}'); + await waitForSelection(textbox, {index: 0, offset: 11}); + }); + + it('skips over token with Control+f from end of preceding text', async () => { + if (!isMacPlatform()) { + return; + } + + let {textbox} = await renderControlledTokenField(abTokCd); + await navigateCaretFromEnd(textbox, abTokCd, {index: 0, offset: 2}); + await userEvent.keyboard('{Control>}f{/Control}'); + await waitForSelection(textbox, {index: 2, offset: 0}); + }); + + it('skips over token with Control+b from start of following text', async () => { + if (!isMacPlatform()) { + return; + } + + let {textbox} = await renderControlledTokenField(abTokCd); + await navigateCaretFromEnd(textbox, abTokCd, {index: 2, offset: 0}); + await userEvent.keyboard('{Control>}b{/Control}'); + await waitForSelection(textbox, {index: 0, offset: 2}); + }); + + it('moves over an emoji as a single grapheme with Control+f and Control+b', async () => { + if (!isMacPlatform()) { + return; + } + + let list = segments(text('a😀b')); + let {textbox} = await renderControlledTokenField(list); + await navigateCaret(textbox, list, {index: 0, offset: 1}); + await userEvent.keyboard('{Control>}f{/Control}'); + await waitForSelection(textbox, {index: 0, offset: 3}); + await userEvent.keyboard('{Control>}b{/Control}'); + await waitForSelection(textbox, {index: 0, offset: 1}); + }); + }); }); describe('rtl caret movement', () => { @@ -804,7 +892,7 @@ describeOrSkip('TokenField browser interactions', () => { it('inserts a newline on Enter in a multiline field', async () => { let {textbox, getValue} = await renderControlledTokenField(segments(text('')), { - multiline: true + allowsNewlines: true }); await focusField(textbox); await userEvent.type(textbox, 'ab'); @@ -818,7 +906,7 @@ describeOrSkip('TokenField browser interactions', () => { return; } // Put multiline text on the clipboard by copying it from a source field. - let source = await renderControlledTokenField(segments(text('a\nb')), {multiline: true}); + let source = await renderControlledTokenField(segments(text('a\nb')), {allowsNewlines: true}); let target = await renderControlledTokenField(segments(text(''))); let mod = modKey(); await commands.lockClipboard(); @@ -838,8 +926,8 @@ describeOrSkip('TokenField browser interactions', () => { if (clipboardRoundTripUnsupported()) { return; } - let source = await renderControlledTokenField(segments(text('a\nb')), {multiline: true}); - let target = await renderControlledTokenField(segments(text('')), {multiline: true}); + let source = await renderControlledTokenField(segments(text('a\nb')), {allowsNewlines: true}); + let target = await renderControlledTokenField(segments(text('')), {allowsNewlines: true}); let mod = modKey(); await commands.lockClipboard(); try { diff --git a/packages/@react-spectrum/ai/test/TokenField.composition.browser.test.tsx b/packages/react-aria-components/test/TokenField.composition.browser.test.tsx similarity index 95% rename from packages/@react-spectrum/ai/test/TokenField.composition.browser.test.tsx rename to packages/react-aria-components/test/TokenField.composition.browser.test.tsx index a7167a73987..8dbaa942989 100644 --- a/packages/@react-spectrum/ai/test/TokenField.composition.browser.test.tsx +++ b/packages/react-aria-components/test/TokenField.composition.browser.test.tsx @@ -41,8 +41,8 @@ import {describe, expect, it} from 'vitest'; import {isFirefox, isWebKit} from 'react-aria/private/utils/platform'; import React from 'react'; import {render} from 'vitest-browser-react'; -import {Token, TokenField} from '../src/TokenField'; -import {TokenSegmentList} from '../src/TokenSegmentList'; +import {Token, TokenField, TokenInput} from '../src/TokenField'; +import {TokenFieldValue} from 'react-stately/useTokenFieldState'; declare module 'vitest/browser' { interface BrowserCommands { @@ -161,7 +161,7 @@ describeOrSkip('TokenField IME composition (Android)', () => { }); itAndroid('composes between two adjacent tokens', async () => { - let list = new TokenSegmentList([token('A'), token('B')]); + let list = new TokenFieldValue([token('A'), token('B')]); let {textbox, getValue} = await renderControlledTokenField(list); // Caret between the two tokens. await navigateCaret(textbox, list, {index: 1, offset: 0}); @@ -175,7 +175,7 @@ describeOrSkip('TokenField IME composition (Android)', () => { // without the compositionend DOM-reconciliation the DOM would show 'TOKhellohello' even // though the model is the correct 'TOKhello'. itAndroid('composes immediately after a lone leading token without duplicating', async () => { - let list = new TokenSegmentList([token('TOK')]); + let list = new TokenFieldValue([token('TOK')]); let {textbox, getValue} = await renderControlledTokenField(list); await focusField(textbox); await userEvent.keyboard('{End}'); @@ -217,7 +217,7 @@ describeOrSkip('TokenField IME composition (Android)', () => { forceRender = () => setTick(t => t + 1); return ( - {segment => {segment.text}} + {segment => {segment.text}} ); } @@ -322,7 +322,7 @@ describeOrSkip('TokenField IME composition (Android)', () => { setValue(v); }} aria-label="onchange-field"> - {segment => {segment.text}} + {segment => {segment.text}} ); } @@ -354,10 +354,10 @@ describeOrSkip('TokenField IME composition (Android)', () => { } function Harness() { // Leading token (rendered via the child fn) followed by editable text. - let [value, setValue] = React.useState(() => new TokenSegmentList([token('A'), text('')])); + let [value, setValue] = React.useState(() => new TokenFieldValue([token('A'), text('')])); return ( - {segment => {segment.text}} + {segment => {segment.text}} ); } @@ -386,7 +386,7 @@ describeOrSkip('TokenField IME composition (Android)', () => { // re-renders so it matches the controlled value rather than the in-progress composition. itAndroid('ends composition early when a controlled update inserts a completion', async () => { let valueRef = {current: segments(text(''))}; - let setValueExternal: (v: TokenSegmentList) => void = () => {}; + let setValueExternal: (v: TokenFieldValue) => void = () => {}; function Harness() { let [value, setValue] = React.useState(() => segments(text(''))); valueRef.current = value; @@ -394,7 +394,7 @@ describeOrSkip('TokenField IME composition (Android)', () => { setValueExternal = setValue; return ( - {segment => {segment.text}} + {segment => {segment.text}} ); } @@ -410,7 +410,7 @@ describeOrSkip('TokenField IME composition (Android)', () => { // The user selects a completion: the parent replaces the value with a token. This does // not match the in-progress composition, so composition ends early and the DOM updates. - setValueExternal(new TokenSegmentList([token('Calendar')])); + setValueExternal(new TokenFieldValue([token('Calendar')])); await expectDOMText(textbox, 'Calendar'); expect(valueRef.current.toString()).toBe('Calendar'); diff --git a/packages/react-aria-components/test/TokenField.test.js b/packages/react-aria-components/test/TokenField.test.js new file mode 100644 index 00000000000..8c655f0eadf --- /dev/null +++ b/packages/react-aria-components/test/TokenField.test.js @@ -0,0 +1,74 @@ +/* + * 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 {Label} from '../src/Label'; +import React from 'react'; +import {render} from '@react-spectrum/test-utils-internal'; +import {Text} from '../src/Text'; +import {Token, TokenField, TokenInput} from '../src/TokenField'; +import {TokenFieldValue} from 'react-stately/useTokenFieldState'; + +let TestTokenField = props => ( + + + {segment => {segment.text}} + Description + +); + +describe('TokenField', () => { + describe('labeling', () => { + it('should link the input to the label via aria-labelledby', () => { + let {getByRole} = render(); + + let input = getByRole('textbox'); + expect(input.closest('.react-aria-TokenField')).toHaveAttribute('data-foo', 'bar'); + expect(input).toHaveClass('react-aria-TokenInput'); + + expect(input).toHaveAttribute('aria-labelledby'); + let label = document.getElementById(input.getAttribute('aria-labelledby')); + expect(label).toHaveAttribute('class', 'react-aria-Label'); + expect(label).toHaveTextContent('Test'); + }); + + it('should link the input to the description via aria-describedby', () => { + let {getByRole} = render(); + + let input = getByRole('textbox'); + expect(input).toHaveAttribute('aria-describedby'); + expect( + input + .getAttribute('aria-describedby') + .split(' ') + .map(id => document.getElementById(id).textContent) + .join(' ') + ).toBe('Description'); + }); + + it('should support aria-label when no visible label is provided', () => { + let {getByRole} = render( + + {segment => {segment.text}} + + ); + + let input = getByRole('textbox'); + expect(input).toHaveAttribute('aria-label', 'Message'); + expect(input).not.toHaveAttribute('aria-labelledby'); + }); + }); +}); diff --git a/packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx similarity index 85% rename from packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx rename to packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx index 80f9e2b25f7..c34831d94a9 100644 --- a/packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx +++ b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx @@ -11,17 +11,18 @@ */ import {expect} from 'vitest'; +import {getSelection, setSelection} from '../../../react-aria/src/tokenfield/useTokenField'; +import {type Locator, userEvent} from 'vitest/browser'; +import {Position, TokenFieldSegment, TokenFieldValue} from 'react-stately/useTokenFieldState'; +import React, {useEffect, useState} from 'react'; +import {render} from 'vitest-browser-react'; import { - getSelection, - setSelection, Token, TokenField, - type TokenFieldProps + type TokenFieldProps, + TokenInput, + type TokenInputProps } from '../../src/TokenField'; -import {type Locator, userEvent} from 'vitest/browser'; -import {Position, TokenFieldSegment, TokenSegmentList} from '../../src/TokenSegmentList'; -import React, {useEffect, useState} from 'react'; -import {render} from 'vitest-browser-react'; export function text(s: string): TokenFieldSegment { return {type: 'text', text: s}; @@ -31,15 +32,15 @@ export function token(s: string): TokenFieldSegment { return {type: 'token', text: s}; } -export function segments(...segs: TokenFieldSegment[]): TokenSegmentList { - return new TokenSegmentList(segs); +export function segments(...segs: TokenFieldSegment[]): TokenFieldValue { + return new TokenFieldValue(segs); } /** Primary fixture: "ab" + token("TOK") + "cd" */ export const abTokCd = segments(text('ab'), token('TOK'), text('cd')); /** Story sample for adjacent-token arrow navigation. */ -export const adjacentTokensSample = new TokenSegmentList([ +export const adjacentTokensSample = new TokenFieldValue([ token('Hello'), text(' tokens testing '), token('World'), @@ -47,11 +48,11 @@ export const adjacentTokensSample = new TokenSegmentList([ text(' test') ]); -export function expectFieldText(value: TokenSegmentList, str: string) { +export function expectFieldText(value: TokenFieldValue, str: string) { expect(value.toString()).toBe(str); } -export function expectCaret(value: TokenSegmentList, pos: Position) { +export function expectCaret(value: TokenFieldValue, pos: Position) { expect(value.caretPosition).toEqual(pos); } @@ -121,7 +122,7 @@ export async function dblClickAt(textbox: Locator, node: Node, offset: number): }); } -export async function navigateCaret(textbox: Locator, list: TokenSegmentList, target: Position) { +export async function navigateCaret(textbox: Locator, list: TokenFieldValue, target: Position) { await focusField(textbox); await userEvent.keyboard('{Home}'); let el = textbox.element(); @@ -137,7 +138,7 @@ export async function navigateCaret(textbox: Locator, list: TokenSegmentList, ta /** Positions the caret by starting at the field end and stepping left. */ export async function navigateCaretFromEnd( textbox: Locator, - _list: TokenSegmentList, + _list: TokenFieldValue, target: Position ) { await focusField(textbox); @@ -154,7 +155,7 @@ export async function navigateCaretFromEnd( export async function selectRange( textbox: Locator, - list: TokenSegmentList, + list: TokenFieldValue, start: Position, end: Position ) { @@ -171,7 +172,7 @@ export async function selectRange( } export interface ControlledTokenFieldResult { - getValue: () => TokenSegmentList; + getValue: () => TokenFieldValue; textbox: Locator; } @@ -179,14 +180,16 @@ interface ControlledProps extends Omit< TokenFieldProps, 'value' | 'defaultValue' | 'onChange' | 'children' > { - initial: TokenSegmentList; - valueRef: React.MutableRefObject; + initial: TokenFieldValue; + valueRef: React.MutableRefObject; + children?: TokenInputProps['children']; } function ControlledTokenField({ initial, valueRef, 'aria-label': ariaLabel = 'Message', + children = segment => {segment.text}, ...props }: ControlledProps) { let [value, setValue] = useState(initial); @@ -195,7 +198,7 @@ function ControlledTokenField({ }, [value, valueRef]); return ( - {segment => {segment.text}} + {children} ); } @@ -203,7 +206,7 @@ function ControlledTokenField({ let fieldInstance = 0; export async function renderControlledTokenField( - initial: TokenSegmentList, + initial: TokenFieldValue, props?: Omit ): Promise { let label = `TokenField-${++fieldInstance}`; @@ -218,10 +221,10 @@ export async function renderControlledTokenField( }; } -export async function waitForFieldText(getValue: () => TokenSegmentList, str: string) { +export async function waitForFieldText(getValue: () => TokenFieldValue, str: string) { await expect.poll(() => getValue().toString()).toBe(str); } -export async function waitForCaret(getValue: () => TokenSegmentList, pos: Position) { +export async function waitForCaret(getValue: () => TokenFieldValue, pos: Position) { await expect.poll(() => getValue().caretPosition).toEqual(pos); } diff --git a/packages/react-aria/exports/useTokenField.ts b/packages/react-aria/exports/useTokenField.ts new file mode 100644 index 00000000000..15b0091b6d2 --- /dev/null +++ b/packages/react-aria/exports/useTokenField.ts @@ -0,0 +1,16 @@ +/* + * 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 + */ + +export {useTokenField, positionToDOMRange} from '../src/tokenfield/useTokenField'; +export {useToken} from '../src/tokenfield/useToken'; +export type {TokenFieldAria, AriaTokenFieldProps} from '../src/tokenfield/useTokenField'; +export type {TokenAria, TokenProps} from '../src/tokenfield/useToken'; diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index a1e3444817c..75c693d1a88 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -30,6 +30,7 @@ import {chain} from '../utils/chain'; import {ComboBoxProps, ComboBoxState, SelectionMode} from 'react-stately/useComboBoxState'; import {dispatchVirtualFocus} from '../focus/virtualFocus'; import { + ElementType, FocusEvent, InputHTMLAttributes, TouchEvent, @@ -48,6 +49,7 @@ import {isAppleDevice} from '../utils/platform'; import {ListKeyboardDelegate} from '../selection/ListKeyboardDelegate'; import {mergeProps} from '../utils/mergeProps'; import {privateValidationStateProp} from 'react-stately/private/form/useFormValidationState'; +import {setInteractionModality} from '../interactions/useFocusVisible'; import {useEvent} from '../utils/useEvent'; import {useFormReset} from '../utils/useFormReset'; import {useId} from '../utils/useId'; @@ -78,6 +80,12 @@ export interface AriaComboBoxOptions exte listBoxRef: RefObject; /** The ref for the optional list box popup trigger button. */ buttonRef?: RefObject; + /** + * The HTML element used to render the label, e.g. 'label', or 'span'. + * + * @default 'label' + */ + labelElementType?: ElementType; /** An optional keyboard delegate implementation, to override the default. */ keyboardDelegate?: KeyboardDelegate; /** @@ -476,7 +484,17 @@ export function useComboBox( ); return { - labelProps, + labelProps: { + ...labelProps, + // Focus the input in case the label is not a native