diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 5e837d9bf92..5eac72d62e0 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 {TokenSegmentList} 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 {TokenSegmentListOptions} from 'react-aria-components/TokenField'; export type {UserMessageProps} from '../src/UserMessage'; diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 7614bcae7d3..4685fa2ddff 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -27,14 +27,13 @@ import { useRef, useState } from 'react'; -// eslint-disable-next-line import { Direction, Position, TokenFieldSegment, TokenSegment, TokenSegmentList -} from './TokenSegmentList'; +} from 'react-stately/useTokenFieldState'; import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {Group} from 'react-aria-components/Group'; import {IconContext, mergeStyles} from '@react-spectrum/s2'; @@ -43,10 +42,15 @@ import {isFileDropItem, useDrop} from 'react-aria-components/useDrop'; import {isFocusable} from 'react-aria/private/utils/isFocusable'; import {Link} from '@react-spectrum/s2/Link'; import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Menu'; -// eslint-disable-next-line 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 {PromptFocusContext} from './Chat'; import Send from '@react-spectrum/s2/icons/ArrowUpSend'; import Stop from '@react-spectrum/s2/icons/StopProcessing'; @@ -336,10 +340,10 @@ export function PromptTokenField(props: PromptTokenFieldProps) { { if (e.isTrusted) { setFocused(true); @@ -371,25 +375,28 @@ export function PromptTokenField(props: PromptTokenFieldProps) { } } : undefined - } - onSubmit={onSubmit} - className={renderProps => - 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})} + + 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})} + + + 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/ComboBoxSegmentList.ts b/packages/dev/s2-docs/pages/react-aria/ComboBoxSegmentList.ts new file mode 100644 index 00000000000..597247b378f --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/ComboBoxSegmentList.ts @@ -0,0 +1,59 @@ +import {Key} from '@react-types/shared'; +import {TokenSegmentList} from 'react-aria-components/TokenField'; + +export class ComboBoxSegmentList extends TokenSegmentList { + 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[]): ComboBoxSegmentList { + 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/TagFieldSegmentList.ts b/packages/dev/s2-docs/pages/react-aria/TagFieldSegmentList.ts new file mode 100644 index 00000000000..0509c81e994 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/TagFieldSegmentList.ts @@ -0,0 +1,23 @@ +import {type TokenFieldSegment, TokenSegmentList} from 'react-aria-components/TokenField'; + +export class TagFieldSegmentList extends TokenSegmentList { + 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..5f804ddac36 --- /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/TokenizingSegmentList.ts"]} + "use client"; + import {Token, TokenField} from 'vanilla-starter/TokenField'; + import {TokenizingSegmentList} from './TokenizingSegmentList'; + + + {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/TokenizingSegmentList.ts"]} + "use client"; + import {Token, TokenField} from 'tailwind-starter/TokenField'; + import {TokenizingSegmentList} from './TokenizingSegmentList'; + + + {segment => {segment.text}} + + ``` + + +## Value + +`TokenField` is controlled via a `TokenSegmentList` 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 {TokenSegmentList} from 'react-stately/useTokenFieldState'; +import {useState} from 'react'; + +function Example() { + let [value, setValue] = useState( + new TokenSegmentList([ + {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 `TokenSegmentList` class to customize how text is converted into tokens. Override the `tokenize` method to parse user input, and use `createSegmentList` to return new instances of your subclass when the value changes. + +```tsx render +"use client"; +import {Token, TokenField} from 'vanilla-starter/TokenField'; +import {TokenizingSegmentList} from './TokenizingSegmentList'; + + + {segment => {segment.text}} + +``` + +## Autocomplete + +Combine `TokenField` with [Autocomplete](Autocomplete) to provide inline completions such as @mentions and slash commands. Use `TokenSegmentList.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, TokenSegmentList} 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 TokenSegmentList([ + {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 `TokenSegmentList` 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/TagFieldSegmentList.ts"]} +"use client"; +import {Token, TokenField} from 'vanilla-starter/TokenField'; +import {TagFieldSegmentList} from './TagFieldSegmentList'; + + + {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 {TokenSegmentList} 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 TokenSegmentList([{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 `TokenSegmentList` with the ComboBox `value` and `inputValue` props using a custom segment list class. + +```tsx render files={["packages/dev/s2-docs/pages/react-aria/ComboBoxSegmentList.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 {ComboBoxSegmentList} from './ComboBoxSegmentList'; +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 ComboBoxSegmentList([])); + + return ( + setValue(value.setSelectedKeys(keys))}> + +
+ + {segment => {segment.text}} + + + + +
+ + + {state => {state.username}} + + +
+ ); +} +``` + +## API + +```tsx links={{TokenField: '#tokenfield', TokenInput: '#token-input', Token: '#token'}} + + +``` + +### TokenField + + + +### TokenInput + + + +### Token + + + +### TokenSegmentList + + diff --git a/packages/dev/s2-docs/pages/react-aria/TokenizingSegmentList.ts b/packages/dev/s2-docs/pages/react-aria/TokenizingSegmentList.ts new file mode 100644 index 00000000000..2b2f1e1949e --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/TokenizingSegmentList.ts @@ -0,0 +1,50 @@ +import {type TokenFieldSegment, TokenSegmentList} from 'react-aria-components/TokenField'; + +export class TokenizingSegmentList extends TokenSegmentList { + tokenRegex: RegExp; + + constructor(tokens: TokenFieldSegment[], tokenRegex: RegExp) { + super(tokens); + this.tokenRegex = tokenRegex; + } + + static tokenize(text: string, tokenRegex: RegExp): TokenSegmentList { + let list = new this([], tokenRegex); + let segments = list.tokenize(text); + return new this(segments, tokenRegex); + } + + createSegmentList(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..b2c73790052 --- /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 {TokenSegmentList, Direction} from 'react-stately/useTokenFieldState'; +export type { + TokenFieldSegment, + TokenSegment, + TextSegment, + Position, + TokenSegmentListOptions +} from 'react-stately/useTokenFieldState'; diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index a03fa5933c3..5e93f6402d4 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 {TokenSegmentList} 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..29b22bf3491 --- /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, + TokenSegment, + TokenSegmentList, + 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 TokenSegmentList = TokenSegmentList +>(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 TokenSegmentList = TokenSegmentList +>(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 90% rename from packages/@react-spectrum/ai/stories/TokenField.stories.tsx rename to packages/react-aria-components/stories/TokenField.stories.tsx index 75fa0cebc63..1a5ca30b3c7 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,26 +17,24 @@ 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, + TokenSegmentList +} 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; @@ -94,13 +91,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 +106,13 @@ export const AutoTokenize: TokenFieldStory = () => { export const Template: TokenFieldStory = () => { return ( - {segment => {segment.text}} + )}> + + {segment => {segment.text}} ); }; @@ -200,8 +198,9 @@ export const WithAutocomplete: TokenFieldStory = () => { return ( - - {segment => {segment.text}} + + + {segment => {segment.text}} { return ( { {type: 'token', text: 'Marketing'}, {type: 'token', text: 'Sales'} ]) - } - aria-label="Categories"> - {segment => {segment.text}} + }> + + {segment => {segment.text}} ); }; @@ -324,8 +323,9 @@ export const Search: TokenFieldStory = () => { return ( - - {segment => {segment.text}} + + + {segment => {segment.text}} { 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 99% rename from packages/@react-spectrum/ai/test/TokenField.browser.test.tsx rename to packages/react-aria-components/test/TokenField.browser.test.tsx index 34a70b33408..e8ce6b990f4 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}} ); @@ -804,7 +806,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 +820,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 +840,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 97% 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..12477ba965e 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 {TokenSegmentList} from 'react-stately/useTokenFieldState'; declare module 'vitest/browser' { interface BrowserCommands { @@ -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}} ); } @@ -357,7 +357,7 @@ describeOrSkip('TokenField IME composition (Android)', () => { let [value, setValue] = React.useState(() => new TokenSegmentList([token('A'), text('')])); return ( - {segment => {segment.text}} + {segment => {segment.text}} ); } @@ -394,7 +394,7 @@ describeOrSkip('TokenField IME composition (Android)', () => { setValueExternal = setValue; return ( - {segment => {segment.text}} + {segment => {segment.text}} ); } 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..bb87d529423 --- /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 {TokenSegmentList} 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 94% rename from packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx rename to packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx index 80f9e2b25f7..65b21095d43 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, TokenSegmentList} 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}; @@ -181,12 +182,14 @@ interface ControlledProps extends Omit< > { initial: TokenSegmentList; 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} ); } 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