From 703751b68ef15b818db20f7e40e6e43fc333ea07 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 7 Jul 2026 17:08:46 -0700 Subject: [PATCH 01/11] refactor TokenField into hooks --- packages/@react-spectrum/ai/exports/index.ts | 4 +- .../@react-spectrum/ai/src/PromptField.tsx | 6 +- .../exports/TokenField.ts | 28 ++ .../react-aria-components/src/TokenField.tsx | 181 +++++++++++ .../stories/TokenField.stories.tsx | 21 +- .../stories/styles.global.css | 4 +- .../test/TokenField.browser.test.tsx | 4 +- .../TokenField.composition.browser.test.tsx | 2 +- .../test/utils/tokenFieldBrowserUtils.tsx | 11 +- packages/react-aria/exports/useTokenField.ts | 16 + .../react-aria/src/tokenfield/useToken.ts | 56 ++++ .../src/tokenfield/useTokenField.ts} | 289 +++++------------- .../exports/useTokenFieldState.ts | 23 ++ .../src/tokenfield}/TokenSegmentList.ts | 0 .../src/tokenfield/useTokenFieldState.ts | 42 +++ .../test/tokenfield}/TokenSegmentList.test.ts | 7 +- 16 files changed, 443 insertions(+), 251 deletions(-) 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 (96%) rename packages/{@react-spectrum/ai => react-aria-components}/stories/styles.global.css (88%) rename packages/{@react-spectrum/ai => react-aria-components}/test/TokenField.browser.test.tsx (99%) rename packages/{@react-spectrum/ai => react-aria-components}/test/TokenField.composition.browser.test.tsx (99%) rename packages/{@react-spectrum/ai => react-aria-components}/test/utils/tokenFieldBrowserUtils.tsx (96%) 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} (74%) create mode 100644 packages/react-stately/exports/useTokenFieldState.ts rename packages/{@react-spectrum/ai/src => react-stately/src/tokenfield}/TokenSegmentList.ts (100%) create mode 100644 packages/react-stately/src/tokenfield/useTokenFieldState.ts rename packages/{@react-spectrum/ai/test => react-stately/test/tokenfield}/TokenSegmentList.test.ts (99%) 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..968d047649d 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,9 @@ 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, 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'; diff --git a/packages/react-aria-components/exports/TokenField.ts b/packages/react-aria-components/exports/TokenField.ts new file mode 100644 index 00000000000..425e594c07e --- /dev/null +++ b/packages/react-aria-components/exports/TokenField.ts @@ -0,0 +1,28 @@ +/* + * 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, Token} from '../src/TokenField'; +export type { + TokenFieldProps, + TokenFieldRenderProps, + TokenProps, + TokenRenderProps +} 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/src/TokenField.tsx b/packages/react-aria-components/src/TokenField.tsx new file mode 100644 index 00000000000..55090b86ff6 --- /dev/null +++ b/packages/react-aria-components/src/TokenField.tsx @@ -0,0 +1,181 @@ +/* + * 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 {FieldInputContext} from './Autocomplete'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {forwardRefType} from '@react-types/shared'; +import {mergeProps} from 'react-aria/mergeProps'; +import {mergeRefs} from 'react-aria/mergeRefs'; +import React, {ForwardedRef, forwardRef, HTMLAttributes, memo} from 'react'; +import {RenderProps, SlotProps, StyleRenderProps, useRenderProps, useSlottedContext} from './utils'; +import {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 { + isReadOnly: boolean; + isDisabled: boolean; + isFocused: boolean; + isFocusVisible: boolean; +} + +export interface TokenFieldProps + extends AriaTokenFieldProps, StyleRenderProps, SlotProps { + children: ( + segment: TokenSegment ? V : never> + ) => React.ReactElement; +} + +export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function TokenField< + T extends TokenSegmentList = TokenSegmentList +>(props: TokenFieldProps, forwardedRef: ForwardedRef) { + let { + onChange, + children, + isReadOnly = false, + isDisabled = false, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy + } = props; + + let fieldCtx = useSlottedContext(FieldInputContext, props.slot); + let { + value: _autocompleteValue, + onChange: onAutocompleteChange, + ref: autocompleteRef, + ...autocompleteProps + } = fieldCtx ?? {}; + + let ref = useObjectRef(forwardedRef); + let state = useTokenFieldState({ + ...props, + onChange: (value: T) => { + onChange?.(value); + onAutocompleteChange?.(value.toString()); + } + }); + + let {tokenFieldProps, isComposing} = useTokenField( + { + ...props, + 'aria-label': ariaLabel ?? autocompleteProps['aria-label'], + 'aria-labelledby': ariaLabelledBy ?? autocompleteProps['aria-labelledby'], + 'aria-describedby': ariaDescribedBy ?? autocompleteProps['aria-describedby'] + }, + state, + ref + ); + + let {isFocused, isFocusVisible, focusProps} = useFocusRing(); + + let renderProps = useRenderProps({ + ...props, + children: undefined, + defaultClassName: 'react-aria-TokenField', + values: { + isFocused, + isFocusVisible, + isDisabled, + isReadOnly + } + }); + + let DOMProps = filterDOMProps(props, {global: true}); + + return ( +
+ )} + ref={mergeRefs(ref, autocompleteRef as any)} + role={autocompleteProps['role'] || 'textbox'} + data-focused={isFocused || undefined} + data-focus-visible={isFocusVisible || undefined} + data-disabled={isDisabled || undefined} + data-readonly={isReadOnly || undefined} + style={{...renderProps.style, ...tokenFieldProps.style}}> + + {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 { + isSelected: boolean; + isDisabled: boolean; +} + +export interface TokenProps extends RenderProps {} + +export const Token = forwardRef(function Token( + props: TokenProps, + ref: ForwardedRef +) { + let objectRef = useObjectRef(ref); + let {tokenProps, isSelected} = useToken(props, {}, objectRef); + + let renderProps = useRenderProps({ + ...props, + defaultClassName: 'react-aria-Token', + values: { + isSelected, + isDisabled: false + } + }); + + 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 96% rename from packages/@react-spectrum/ai/stories/TokenField.stories.tsx rename to packages/react-aria-components/stories/TokenField.stories.tsx index 75fa0cebc63..5572a9b66ce 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} 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; diff --git a/packages/@react-spectrum/ai/stories/styles.global.css b/packages/react-aria-components/stories/styles.global.css similarity index 88% rename from packages/@react-spectrum/ai/stories/styles.global.css rename to packages/react-aria-components/stories/styles.global.css index 665027e5685..b60085e25c6 100644 --- a/packages/@react-spectrum/ai/stories/styles.global.css +++ b/packages/react-aria-components/stories/styles.global.css @@ -1,5 +1,5 @@ -@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 { padding: 4px 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..37e9e3465e8 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} from '../src/TokenField'; + +const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json'; declare module 'vitest/browser' { interface BrowserCommands { 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 99% 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..3a56cd1a370 100644 --- a/packages/@react-spectrum/ai/test/TokenField.composition.browser.test.tsx +++ b/packages/react-aria-components/test/TokenField.composition.browser.test.tsx @@ -42,7 +42,7 @@ 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 {TokenSegmentList} from 'react-stately/useTokenFieldState'; declare module 'vitest/browser' { interface BrowserCommands { diff --git a/packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx similarity index 96% rename from packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx rename to packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx index 80f9e2b25f7..53709e2e91b 100644 --- a/packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx +++ b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx @@ -11,17 +11,12 @@ */ import {expect} from 'vitest'; -import { - getSelection, - setSelection, - Token, - TokenField, - type TokenFieldProps -} from '../../src/TokenField'; +import {getSelection, setSelection} from '../../../react-aria/src/tokenfield/useTokenField'; import {type Locator, userEvent} from 'vitest/browser'; -import {Position, TokenFieldSegment, TokenSegmentList} from '../../src/TokenSegmentList'; +import {Position, TokenFieldSegment, TokenSegmentList} from 'react-stately/useTokenFieldState'; import React, {useEffect, useState} from 'react'; import {render} from 'vitest-browser-react'; +import {Token, TokenField, type TokenFieldProps} from '../../src/TokenField'; export function text(s: string): TokenFieldSegment { return {type: 'text', text: s}; 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/tokenfield/useToken.ts b/packages/react-aria/src/tokenfield/useToken.ts new file mode 100644 index 00000000000..fdb00148929 --- /dev/null +++ b/packages/react-aria/src/tokenfield/useToken.ts @@ -0,0 +1,56 @@ +/* + * 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 + */ + +import {HTMLAttributes, RefObject, useRef, useState} from 'react'; +import {useEvent} from 'react-aria/private/utils/useEvent'; + +export interface TokenProps {} + +export interface TokenAria { + tokenProps: HTMLAttributes; + isSelected: boolean; +} + +export function useToken( + // Unused but matches the normal signature. + _props: TokenProps, + _state: any, + ref: RefObject +): TokenAria { + let [isSelected, setSelected] = useState(false); + + useEvent(useRef(document), 'selectionchange', () => { + let selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !ref.current) { + return; + } + + let range = selection.getRangeAt(0); + if (!range.collapsed && range.intersectsNode(ref.current)) { + setSelected(true); + } else { + setSelected(false); + } + }); + + return { + tokenProps: { + contentEditable: false, + suppressContentEditableWarning: true, + style: { + userSelect: 'all', + WebkitUserSelect: 'all' + } + }, + isSelected + }; +} diff --git a/packages/@react-spectrum/ai/src/TokenField.tsx b/packages/react-aria/src/tokenfield/useTokenField.ts similarity index 74% rename from packages/@react-spectrum/ai/src/TokenField.tsx rename to packages/react-aria/src/tokenfield/useTokenField.ts index 7563d992599..04143a5d3e9 100644 --- a/packages/@react-spectrum/ai/src/TokenField.tsx +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -7,101 +7,62 @@ * 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. + * governing */ -import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; +import {announce} from '../live-announcer/LiveAnnouncer'; +import {AriaLabelingProps, FocusableProps} from '@react-types/shared'; import { Direction, Position, + TokenFieldProps, TokenFieldSegment, - TokenSegment, + TokenFieldState, TokenSegmentList -} from './TokenSegmentList'; -import {FieldInputContext} from 'react-aria-components/Autocomplete'; -import {filterDOMProps} from 'react-aria/filterDOMProps'; -import {FocusableProps, forwardRefType} from '@react-types/shared'; -import {getActiveElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; -import {getOwnerDocument} from 'react-aria/private/utils/domHelpers'; -import {isMac} from 'react-aria/private/utils/platform'; -import {mergeProps} from 'react-aria/mergeProps'; -import {mergeRefs} from 'react-aria/mergeRefs'; -import React, { - ForwardedRef, - forwardRef, - HTMLAttributes, - memo, - useCallback, - useMemo, - useRef, - useState -} from 'react'; -import {RenderProps, StyleRenderProps, useRenderProps} from 'react-aria-components/useRenderProps'; -import {SlotProps, useSlottedContext} from 'react-aria-components/slots'; -import {useControlledState} from 'react-stately/useControlledState'; -import {useEvent} from 'react-aria/private/utils/useEvent'; -import {useFocusable} from 'react-aria/useFocusable'; -import {useFocusRing} from 'react-aria/useFocusRing'; -import {useKeyboard} from 'react-aria/useKeyboard'; -import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; -import {useLocale} from 'react-aria/I18nProvider'; -import {useObjectRef} from 'react-aria/useObjectRef'; - -export type {TokenFieldSegment}; - -interface TokenFieldRenderProps { - isReadOnly: boolean; - isDisabled: boolean; - isFocused: boolean; - isFocusVisible: boolean; -} - -export interface TokenFieldProps - extends StyleRenderProps, SlotProps, FocusableProps { - value?: T; - defaultValue?: T; - onChange?: (value: T) => void; - children: ( - segment: TokenSegment ? V : never> - ) => React.ReactElement; +} from 'react-stately/useTokenFieldState'; +import {getActiveElement} from '../utils/shadowdom/DOMFunctions'; +import {getOwnerDocument} from '../utils/domHelpers'; +import {HTMLAttributes, RefObject, useCallback, useMemo, useRef, useState} from 'react'; +import {isMac} from '../utils/platform'; +import {mergeProps} from '../utils/mergeProps'; +import {useEvent} from '../utils/useEvent'; +import {useFocusable} from '../interactions/useFocusable'; +import {useKeyboard} from '../interactions/useKeyboard'; +import {useLayoutEffect} from '../utils/useLayoutEffect'; +import {useLocale} from '../i18n/I18nProvider'; + +export interface AriaTokenFieldProps + extends TokenFieldProps, FocusableProps, AriaLabelingProps { multiline?: boolean; isReadOnly?: boolean; isDisabled?: boolean; - 'aria-label'?: string; - 'aria-labelledby'?: string; - 'aria-describedby'?: string; onPaste?: (e: ClipboardEvent) => void; onSubmit?: () => void; } -export const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json'; +export interface TokenFieldAria { + tokenFieldProps: HTMLAttributes; + isComposing: boolean; +} -export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function TokenField< - T extends TokenSegmentList = TokenSegmentList ->(props: TokenFieldProps, forwardedRef: ForwardedRef) { +const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json'; + +export function useTokenField( + props: AriaTokenFieldProps, + state: TokenFieldState, + ref: RefObject +): TokenFieldAria { let { - value: valueProp, - defaultValue: defaultValueProp = new TokenSegmentList([]), - onChange, - children, multiline = false, isReadOnly = false, isDisabled = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, - 'aria-describedby': ariaDescribedBy + 'aria-describedby': ariaDescribedBy, + 'aria-details': ariaDetails } = props; - let fieldCtx = useSlottedContext(FieldInputContext, props.slot); - let { - value: _autocompleteValue, - onChange: onAutocompleteChange, - ref: autocompleteRef, - ...autocompleteProps - } = fieldCtx ?? {}; - - let ref = useObjectRef(forwardedRef); - let [state, setState] = useControlledState(valueProp, defaultValueProp, onChange); + let {value} = state; let {locale} = useLocale(); let graphemeSegmenter = useMemo( () => new Intl.Segmenter(locale, {granularity: 'grapheme'}), @@ -114,10 +75,9 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function let nextValue = useRef(null); let apply = (fn: (value: TokenSegmentList) => TokenSegmentList) => { - setState(value => { + state.setValue(value => { let newValue = fn(value); nextValue.current = newValue; - onAutocompleteChange?.(newValue.toString()); return newValue; }); }; @@ -163,25 +123,25 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function // If a prop update occurs during composition that doesn't match the expected value, // end composition and re-render the controlled value. useLayoutEffect(() => { - if (isComposing && state !== nextValue.current) { + if (isComposing && value !== nextValue.current) { stopComposition(); } - nextValue.current = state; + nextValue.current = value; }); let caretPosition = useRef(null); useLayoutEffect(() => { if ( ref.current && - state.caretPosition && + value.caretPosition && !isComposing && - state.caretPosition !== caretPosition.current + value.caretPosition !== caretPosition.current ) { // Only move the caret when the field is already focused. if (ref.current === getActiveElement(getOwnerDocument(ref.current))) { - setCursor(ref.current, state.caretPosition); + setCursor(ref.current, value.caretPosition); } - caretPosition.current = state.caretPosition; + caretPosition.current = value.caretPosition; } }); @@ -333,7 +293,7 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function return; } let [start, end] = selection; - let slice = state.slice(start, end); + let slice = value.slice(start, end); let dataTransfer = 'clipboardData' in e ? e.clipboardData : e.dataTransfer; dataTransfer?.setData(CLIPBOARD_MIME_TYPE, JSON.stringify(slice.segments)); dataTransfer?.setData('text/plain', slice.toString()); @@ -377,23 +337,23 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function return; } - state.endCoalescing(); + value.endCoalescing(); // When the cursor moves next to a token, announce it. // Otherwise the screen reader will only announce the first/last character. if (window.getSelection()?.isCollapsed) { let [start, end] = getSelection(ref.current!)!; if (start.offset === 0) { - let segment = state.segments[start.index]; + let segment = value.segments[start.index]; if (segment?.type !== 'token') { - segment = state.segments[start.index - 1]; + segment = value.segments[start.index - 1]; } if (segment?.type === 'token') { announce(segment.text, 'assertive'); } // Update the caret position in the value. - setState(value => value.withCaretPosition(end)); + state.setValue(value => value.withCaretPosition(end)); } } }); @@ -407,8 +367,8 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function return; } - let start = state.findLineBoundary(selection[0], Direction.Backward); - let end = state.findLineBoundary(selection[1], Direction.Forward); + let start = value.findLineBoundary(selection[0], Direction.Backward); + let end = value.findLineBoundary(selection[1], Direction.Forward); if (start && end) { e.preventDefault(); setSelection(ref.current!, start, end, true); @@ -495,7 +455,7 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function if (!selection) { return false; } - let boundary = state.findLineBoundary(selection[0], Direction.Backward); + let boundary = value.findLineBoundary(selection[0], Direction.Backward); if (boundary) { setCursor(ref.current!, boundary, true); return true; @@ -507,7 +467,7 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function if (!selection) { return false; } - let boundary = state.findLineBoundary(selection[1], Direction.Forward); + let boundary = value.findLineBoundary(selection[1], Direction.Forward); if (boundary) { setCursor(ref.current!, boundary, true); return true; @@ -536,128 +496,26 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function } }); - let {isFocused, isFocusVisible, focusProps} = useFocusRing(); let {focusableProps} = useFocusable(props, ref); - let renderProps = useRenderProps({ - ...props, - children: undefined, - defaultClassName: 'react-aria-TokenField', - values: { - isFocused, - isFocusVisible, - isDisabled, - isReadOnly - } - }); - - let DOMProps = filterDOMProps(props, {global: true}); - - return ( -
, - keyboardProps, - {onPaste: props.onPaste} - )} - ref={mergeRefs(ref, autocompleteRef as any)} - role={autocompleteProps['role'] || 'textbox'} - contentEditable={!isDisabled && !isReadOnly} - suppressContentEditableWarning - aria-multiline={multiline} - aria-label={ariaLabel ?? autocompleteProps['aria-label']} - aria-labelledby={ariaLabelledBy ?? autocompleteProps['aria-labelledby']} - aria-describedby={ariaDescribedBy ?? autocompleteProps['aria-describedby']} - aria-readonly={isReadOnly || undefined} - aria-disabled={isDisabled || undefined} - data-focused={isFocused || undefined} - data-focus-visible={isFocusVisible || undefined} - data-disabled={isDisabled || undefined} - data-readonly={isReadOnly || undefined} - style={{...renderProps.style, whiteSpace: 'pre-wrap'}}> - - {state.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.segments.at(-1)?.text.endsWith('\n') &&
} -
-
- ); -}); - -interface TokenRenderProps { - isSelected: boolean; - isDisabled: boolean; + return { + tokenFieldProps: mergeProps(focusableProps, keyboardProps, { + onPaste: props.onPaste, + contentEditable: !isDisabled && !isReadOnly, + suppressContentEditableWarning: true, + 'aria-multiline': multiline, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + 'aria-details': ariaDetails, + 'aria-readonly': isReadOnly, + 'aria-disabled': isDisabled, + style: {whiteSpace: 'pre-wrap'} + }), + isComposing + }; } -export interface TokenProps extends RenderProps {} - -export const Token = forwardRef(function Token( - props: TokenProps, - ref: ForwardedRef -) { - let objectRef = useObjectRef(ref); - let [isSelected, setSelected] = useState(false); - - useEvent(useRef(document), 'selectionchange', () => { - let selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || !objectRef.current) { - return; - } - - let range = selection.getRangeAt(0); - if (!range.collapsed && range.intersectsNode(objectRef.current)) { - setSelected(true); - } else { - setSelected(false); - } - }); - - let renderProps = useRenderProps({ - ...props, - defaultClassName: 'react-aria-Token', - values: { - isSelected, - isDisabled: false - } - }); - - return ( - - {renderProps.children} - - ); -}); - function indexOfNode(node: Node) { let index = 0; let n: Node | null = node; @@ -677,10 +535,7 @@ export function getSelection(container: Element): [Position, Position] | null { return rangeToPositions(container, range); } -export function rangeToPositions( - container: Element, - range: Range | StaticRange -): [Position, Position] { +function rangeToPositions(container: Element, range: Range | StaticRange): [Position, Position] { let start = getPosition(container, range.startContainer, range.startOffset); let end = getPosition(container, range.endContainer, range.endOffset); return [start, end]; @@ -732,11 +587,11 @@ function getPosition(container: Element, node: Node, offset: number): Position { let isProgrammaticSelectionChange = Symbol('isProgrammaticSelectionChange'); -// TODO: do we want to export these? -export function setCursor(root: Element, pos: Position, fireEvent = false) { +function setCursor(root: Element, pos: Position, fireEvent = false) { setSelection(root, pos, pos, fireEvent); } +// Exported for tests. export function setSelection(root: Element, start: Position, end: Position, fireEvent = false) { let selection = window.getSelection(); if (selection) { @@ -854,6 +709,7 @@ function useMutationTracker(ref: React.RefObject) { mutationTracker.current = null; } }), + // eslint-disable-next-line react-hooks/exhaustive-deps - conflicts with compiler [] ); } @@ -894,10 +750,3 @@ function trackMutations(element: Element) { } }; } - -// 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-stately/exports/useTokenFieldState.ts b/packages/react-stately/exports/useTokenFieldState.ts new file mode 100644 index 00000000000..a367a573554 --- /dev/null +++ b/packages/react-stately/exports/useTokenFieldState.ts @@ -0,0 +1,23 @@ +/* + * 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 {useTokenFieldState} from '../src/tokenfield/useTokenFieldState'; +export {TokenSegmentList, Direction} from '../src/tokenfield/TokenSegmentList'; + +export type {TokenFieldProps, TokenFieldState} from '../src/tokenfield/useTokenFieldState'; +export type { + TokenFieldSegment, + TokenSegment, + TextSegment, + Position, + TokenSegmentListOptions +} from '../src/tokenfield/TokenSegmentList'; diff --git a/packages/@react-spectrum/ai/src/TokenSegmentList.ts b/packages/react-stately/src/tokenfield/TokenSegmentList.ts similarity index 100% rename from packages/@react-spectrum/ai/src/TokenSegmentList.ts rename to packages/react-stately/src/tokenfield/TokenSegmentList.ts diff --git a/packages/react-stately/src/tokenfield/useTokenFieldState.ts b/packages/react-stately/src/tokenfield/useTokenFieldState.ts new file mode 100644 index 00000000000..268bba41672 --- /dev/null +++ b/packages/react-stately/src/tokenfield/useTokenFieldState.ts @@ -0,0 +1,42 @@ +/* + * 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 {TokenSegmentList} from './TokenSegmentList'; +import {useControlledState} from '../utils/useControlledState'; + +export interface TokenFieldProps { + value?: T; + defaultValue?: T; + onChange?: (value: T) => void; +} + +export interface TokenFieldState { + value: T; + setValue: (fn: T | ((value: T) => T)) => void; +} + +export function useTokenFieldState( + props: TokenFieldProps +) { + let { + value: valueProp, + defaultValue: defaultValueProp = new TokenSegmentList([]), + onChange + } = props; + + let [value, setValue] = useControlledState(valueProp, defaultValueProp, onChange); + + return { + value, + setValue + }; +} diff --git a/packages/@react-spectrum/ai/test/TokenSegmentList.test.ts b/packages/react-stately/test/tokenfield/TokenSegmentList.test.ts similarity index 99% rename from packages/@react-spectrum/ai/test/TokenSegmentList.test.ts rename to packages/react-stately/test/tokenfield/TokenSegmentList.test.ts index a4b1a27fb1f..12bbcb47c78 100644 --- a/packages/@react-spectrum/ai/test/TokenSegmentList.test.ts +++ b/packages/react-stately/test/tokenfield/TokenSegmentList.test.ts @@ -10,7 +10,12 @@ * governing permissions and limitations under the License. */ -import {Direction, Position, TokenFieldSegment, TokenSegmentList} from '../src/TokenSegmentList'; +import { + Direction, + Position, + TokenFieldSegment, + TokenSegmentList +} from '../../src/tokenfield/TokenSegmentList'; import React from 'react'; function text(s: string): TokenFieldSegment { From c0b8eabc0f53beffbd6b21ec59b8b0101015f2e6 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Jul 2026 10:32:02 -0700 Subject: [PATCH 02/11] Add docs --- .../pages/react-aria/ComboBoxSegmentList.ts | 59 +++ .../pages/react-aria/TagFieldSegmentList.ts | 23 + .../s2-docs/pages/react-aria/TokenField.mdx | 479 ++++++++++++++++++ .../pages/react-aria/TokenizingSegmentList.ts | 50 ++ .../react-aria-components/exports/index.ts | 8 + .../react-aria-components/src/TokenField.tsx | 86 +++- .../react-aria/src/tokenfield/useToken.ts | 2 +- .../src/tokenfield/useTokenField.ts | 49 +- .../src/tokenfield/TokenSegmentList.ts | 8 + .../src/tokenfield/useTokenFieldState.ts | 9 +- starters/docs/src/TokenField.css | 60 +++ starters/docs/src/TokenField.tsx | 26 + starters/tailwind/src/TokenField.tsx | 53 ++ 13 files changed, 895 insertions(+), 17 deletions(-) create mode 100644 packages/dev/s2-docs/pages/react-aria/ComboBoxSegmentList.ts create mode 100644 packages/dev/s2-docs/pages/react-aria/TagFieldSegmentList.ts create mode 100644 packages/dev/s2-docs/pages/react-aria/TokenField.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/TokenizingSegmentList.ts create mode 100644 starters/docs/src/TokenField.css create mode 100644 starters/docs/src/TokenField.tsx create mode 100644 starters/tailwind/src/TokenField.tsx 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..5f9d86151a8 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx @@ -0,0 +1,479 @@ +import {Layout} from '../../src/Layout'; +export default Layout; + +import docs from 'docs:react-aria-components'; +import '../../tailwind/tailwind.css'; + +export const tags = ['input', 'tokens', 'mentions', 'tags', 'autocomplete']; +export const version = 'alpha'; +export const relatedPages = [{'title': 'useTokenField', 'url': 'TokenField/useTokenField.html'}]; +export const description = 'Allows users to enter text with inline tokens, such as hashtags, mentions, tags, or template variables.'; + +# TokenField + +{docs.exports.TokenField.description} + + + ```tsx render docs={docs.exports.TokenField} links={docs.links} props={['multiline', 'isDisabled', 'isReadOnly']} initialProps={{multiline: 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={docs.exports.TokenField} links={docs.links} props={['multiline', 'isDisabled', 'isReadOnly']} initialProps={{multiline: 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` and `onChange` props to manage the field state, or `defaultValue` for uncontrolled usage. + +```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. + +The following example automatically tokenizes hashtags and @mentions as the user types. + +```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. The following 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. The following 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', Token: '#token'}} + + {segment => } + +``` + +### TokenField + + + +### 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/index.ts b/packages/react-aria-components/exports/index.ts index a03fa5933c3..f18d1ba8b4e 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, Token} from '../src/TokenField'; +export {TokenSegmentList} from 'react-stately/useTokenFieldState'; export { UNSTABLE_Toast, UNSTABLE_ToastList, @@ -494,6 +496,12 @@ export type { } from '../src/TagGroup'; export type {TextAreaProps} from '../src/TextArea'; export type {TextFieldProps, TextFieldRenderProps} from '../src/TextField'; +export type { + TokenFieldProps, + TokenFieldRenderProps, + TokenProps, + TokenRenderProps +} from '../src/TokenField'; export type {TextProps} from '../src/Text'; export type { ToastRegionProps, diff --git a/packages/react-aria-components/src/TokenField.tsx b/packages/react-aria-components/src/TokenField.tsx index 55090b86ff6..6d3c58dbe61 100644 --- a/packages/react-aria-components/src/TokenField.tsx +++ b/packages/react-aria-components/src/TokenField.tsx @@ -11,32 +11,80 @@ */ import {AriaTokenFieldProps} from 'react-aria/useTokenField'; +import { + ClassNameOrFunction, + RenderProps, + SlotProps, + StyleRenderProps, + useRenderProps, + useSlottedContext +} from './utils'; import {FieldInputContext} from './Autocomplete'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {forwardRefType} from '@react-types/shared'; +import {HoverProps, useHover} from 'react-aria/useHover'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import React, {ForwardedRef, forwardRef, HTMLAttributes, memo} from 'react'; -import {RenderProps, SlotProps, StyleRenderProps, useRenderProps, useSlottedContext} from './utils'; import {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 { - isReadOnly: boolean; - isDisabled: boolean; + /** + * Whether the token field is currently hovered with a mouse. + * + * @selector [data-hovered] + */ + isHovered: boolean; + /** + * Whether the token field is focused, either via a mouse or keyboard. + * + * @selector [data-focused] + */ isFocused: boolean; + /** + * Whether the token field is keyboard focused. + * + * @selector [data-focus-visible] + */ isFocusVisible: boolean; + /** + * 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, StyleRenderProps, SlotProps { + extends AriaTokenFieldProps, HoverProps, StyleRenderProps, SlotProps { + /** + * 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-TokenField' + */ + className?: ClassNameOrFunction; } +/** + * A token field allows users to enter text with inline tokens, such as hashtags, mentions, tags, or + * template variables. + */ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function TokenField< T extends TokenSegmentList = TokenSegmentList >(props: TokenFieldProps, forwardedRef: ForwardedRef) { @@ -70,6 +118,7 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function let {tokenFieldProps, isComposing} = useTokenField( { ...props, + role: props.role || autocompleteProps['role'] || 'textbox', 'aria-label': ariaLabel ?? autocompleteProps['aria-label'], 'aria-labelledby': ariaLabelledBy ?? autocompleteProps['aria-labelledby'], 'aria-describedby': ariaDescribedBy ?? autocompleteProps['aria-describedby'] @@ -78,6 +127,7 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function ref ); + let {isHovered, hoverProps} = useHover(props); let {isFocused, isFocusVisible, focusProps} = useFocusRing(); let renderProps = useRenderProps({ @@ -85,6 +135,7 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function children: undefined, defaultClassName: 'react-aria-TokenField', values: { + isHovered, isFocused, isFocusVisible, isDisabled, @@ -100,11 +151,11 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function DOMProps, renderProps, focusProps, + hoverProps, tokenFieldProps, autocompleteProps as HTMLAttributes )} ref={mergeRefs(ref, autocompleteRef as any)} - role={autocompleteProps['role'] || 'textbox'} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} data-disabled={isDisabled || undefined} @@ -136,12 +187,33 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function }); 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 {} +export interface TokenProps extends RenderProps { + /** + * 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 @@ -154,7 +226,7 @@ export const Token = forwardRef(function Token( defaultClassName: 'react-aria-Token', values: { isSelected, - isDisabled: false + isDisabled: false // TODO } }); diff --git a/packages/react-aria/src/tokenfield/useToken.ts b/packages/react-aria/src/tokenfield/useToken.ts index fdb00148929..2da17c65b70 100644 --- a/packages/react-aria/src/tokenfield/useToken.ts +++ b/packages/react-aria/src/tokenfield/useToken.ts @@ -28,7 +28,7 @@ export function useToken( ): TokenAria { let [isSelected, setSelected] = useState(false); - useEvent(useRef(document), 'selectionchange', () => { + useEvent(useRef(typeof document !== 'undefined' ? document : null), 'selectionchange', () => { let selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || !ref.current) { return; diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 04143a5d3e9..b6aaf763e1d 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -12,6 +12,15 @@ import {announce} from '../live-announcer/LiveAnnouncer'; import {AriaLabelingProps, FocusableProps} from '@react-types/shared'; +import { + ClipboardEventHandler, + HTMLAttributes, + RefObject, + useCallback, + useMemo, + useRef, + useState +} from 'react'; import { Direction, Position, @@ -22,7 +31,6 @@ import { } from 'react-stately/useTokenFieldState'; import {getActiveElement} from '../utils/shadowdom/DOMFunctions'; import {getOwnerDocument} from '../utils/domHelpers'; -import {HTMLAttributes, RefObject, useCallback, useMemo, useRef, useState} from 'react'; import {isMac} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; import {useEvent} from '../utils/useEvent'; @@ -33,11 +41,37 @@ import {useLocale} from '../i18n/I18nProvider'; export interface AriaTokenFieldProps extends TokenFieldProps, FocusableProps, AriaLabelingProps { + /** + * The accessibility role of the token field. + * + * @default 'textbox' + */ + role?: 'textbox' | 'searchbox' | 'combobox'; + /** Whether the token field allows newlines. */ multiline?: boolean; + /** Whether the token field is read only. */ isReadOnly?: boolean; + /** Whether the token field is disabled. */ isDisabled?: boolean; - onPaste?: (e: ClipboardEvent) => void; + /** A function that is called when the user presses the Enter key. */ onSubmit?: () => void; + /** + * Handler that is called when the user copies text. See + * [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncopy). + */ + onCopy?: ClipboardEventHandler; + + /** + * Handler that is called when the user cuts text. See + * [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncut). + */ + onCut?: ClipboardEventHandler; + + /** + * Handler that is called when the user pastes text. See + * [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/onpaste). + */ + onPaste?: ClipboardEventHandler; } export interface TokenFieldAria { @@ -53,6 +87,7 @@ export function useTokenField( ref: RefObject ): TokenFieldAria { let { + role = 'textbox', multiline = false, isReadOnly = false, isDisabled = false, @@ -479,6 +514,8 @@ export function useTokenField( let {keyboardProps} = useKeyboard({ isDisabled: isDisabled || isReadOnly, onKeyDown: e => { + props.onKeyDown?.(e); + // mini version of useKeyboard shortcuts until it is merged. let modifiers = ['Shift', 'Control', 'Alt', 'Meta'] satisfies React.ModifierKey[]; let modifierKeys = modifiers.filter(modifier => e.getModifierState(modifier)); @@ -493,7 +530,8 @@ export function useTokenField( } e.continuePropagation(); - } + }, + onKeyUp: props.onKeyUp }); let {focusableProps} = useFocusable(props, ref); @@ -501,8 +539,11 @@ export function useTokenField( return { tokenFieldProps: mergeProps(focusableProps, keyboardProps, { onPaste: props.onPaste, + onCopy: props.onCopy, + onCut: props.onCut, contentEditable: !isDisabled && !isReadOnly, suppressContentEditableWarning: true, + role, 'aria-multiline': multiline, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, @@ -667,7 +708,7 @@ function isValidSegment(segment: unknown): segment is TokenFieldSegment { } function useSelectionChange(ref: React.RefObject, handler: () => void) { - useEvent(useRef(document), 'selectionchange', () => { + useEvent(useRef(typeof document !== 'undefined' ? document : null), 'selectionchange', () => { if (ref.current && ref.current[isProgrammaticSelectionChange]) { ref.current[isProgrammaticSelectionChange] = false; return; diff --git a/packages/react-stately/src/tokenfield/TokenSegmentList.ts b/packages/react-stately/src/tokenfield/TokenSegmentList.ts index 324fae69297..d6ce230347c 100644 --- a/packages/react-stately/src/tokenfield/TokenSegmentList.ts +++ b/packages/react-stately/src/tokenfield/TokenSegmentList.ts @@ -44,13 +44,16 @@ export interface TokenSegmentListOptions { * A list of segments containing editable text and non-editable tokens. */ export class TokenSegmentList { + /** The text and token segments in the list. */ readonly segments: readonly TokenFieldSegment[]; + /** The caret position. */ caretPosition: Position = {index: 0, offset: 0}; // Linked list representing the undo/redo history. private previous: this | null = null; private next: this | null = null; private isCoalescing = true; + /** Create a new list with the given segments. */ constructor(tokens: readonly TokenFieldSegment[], options?: TokenSegmentListOptions) { this.segments = tokens; this.caretPosition = options?.caretPosition ?? {index: 0, offset: 0}; @@ -64,6 +67,7 @@ export class TokenSegmentList { return new Constructor(segments); } + /** Create a new list with the caret position set to the given position. */ withCaretPosition(caretPosition: Position): this { if ( this.caretPosition.index === caretPosition.index && @@ -369,18 +373,22 @@ export class TokenSegmentList { return this.createSegmentList(result); } + /** Convert the list to a string. */ toString(): string { return this.segments.map(seg => seg.text).join(''); } + /** Returns the previous list in the undo history. */ undo(): this { return this.previous ?? this; } + /** Returns the next list in the redo history. */ redo(): this { return this.next ?? this; } + /** End coalescing undo/redo history. */ endCoalescing(): void { this.isCoalescing = false; } diff --git a/packages/react-stately/src/tokenfield/useTokenFieldState.ts b/packages/react-stately/src/tokenfield/useTokenFieldState.ts index 268bba41672..0c6f268d0e5 100644 --- a/packages/react-stately/src/tokenfield/useTokenFieldState.ts +++ b/packages/react-stately/src/tokenfield/useTokenFieldState.ts @@ -12,12 +12,11 @@ import {TokenSegmentList} from './TokenSegmentList'; import {useControlledState} from '../utils/useControlledState'; +import {ValueBase} from '@react-types/shared'; -export interface TokenFieldProps { - value?: T; - defaultValue?: T; - onChange?: (value: T) => void; -} +export interface TokenFieldProps< + T extends TokenSegmentList = TokenSegmentList +> extends ValueBase {} export interface TokenFieldState { value: T; diff --git a/starters/docs/src/TokenField.css b/starters/docs/src/TokenField.css new file mode 100644 index 00000000000..13de427585b --- /dev/null +++ b/starters/docs/src/TokenField.css @@ -0,0 +1,60 @@ +@import './theme.css'; +@import './utilities.css'; + +.react-aria-TokenField { + padding: var(--spacing-2) var(--spacing-3); + outline: none; + border-radius: var(--radius); + outline: none; + width: 100%; + box-sizing: border-box; + font: var(--font-size) system-ui; + line-height: 1.2em; + color: var(--text-color); + + &:empty::before { + content: attr(data-placeholder); + color: var(--text-color-placeholder); + } + + &[aria-multiline='true'] { + min-height: 100px; + padding: var(--spacing-3); + } + + &[data-focused] { + outline: 2px solid var(--focus-ring-color); + outline-offset: -1px; + } + + &[data-disabled] { + color: var(--text-color-disabled); + user-select: none; + } +} + +.react-aria-Token { + background-color: var(--tint-200); + color: var(--tint-1200); + border-radius: 9999px; + height: var(--spacing-5); + padding: 1px var(--spacing-2); + margin: 0 2px; + cursor: default; + white-space: nowrap; + -webkit-tap-highlight-color: transparent; + + &[data-selected] { + background-color: var(--highlight-background); + color: white; + } + + &::selection { + background-color: transparent; + } + + [data-disabled] & { + background-color: var(--gray-200); + color: var(--text-color-disabled); + } +} diff --git a/starters/docs/src/TokenField.tsx b/starters/docs/src/TokenField.tsx new file mode 100644 index 00000000000..0715ad5de8b --- /dev/null +++ b/starters/docs/src/TokenField.tsx @@ -0,0 +1,26 @@ +'use client'; +import { + TokenField as AriaTokenField, + Token as AriaToken, + TokenFieldProps as AriaTokenFieldProps, + TokenProps +} from 'react-aria-components/TokenField'; +import './TokenField.css'; + +export interface TokenFieldProps extends AriaTokenFieldProps { + placeholder?: string; +} + +export function TokenField(props: TokenFieldProps) { + return ( + + ); +} + +export function Token(props: TokenProps) { + return ; +} diff --git a/starters/tailwind/src/TokenField.tsx b/starters/tailwind/src/TokenField.tsx new file mode 100644 index 00000000000..9508c912e4f --- /dev/null +++ b/starters/tailwind/src/TokenField.tsx @@ -0,0 +1,53 @@ +'use client'; +import React from 'react'; +import { + TokenField as AriaTokenField, + Token as AriaToken, + type TokenFieldProps, + type TokenProps +} from 'react-aria-components/TokenField'; +import {composeRenderProps} from 'react-aria-components/composeRenderProps'; +import {tv} from 'tailwind-variants'; +import {fieldBorderStyles} from './Field'; +import {focusRing} from './utils'; + +const tokenFieldStyles = tv({ + extend: focusRing, + base: 'group border-1 rounded-lg font-sans text-sm py-2 px-3 [&[aria-multiline=true]]:min-h-[100px] box-border w-full transition bg-white dark:bg-neutral-900 forced-colors:bg-[Field] text-neutral-800 dark:text-neutral-200 outline-0 disabled:text-neutral-200 dark:disabled:text-neutral-600 disabled:select-none [-webkit-tap-highlight-color:transparent]', + variants: { + isFocused: fieldBorderStyles.variants.isFocusWithin, + isDisabled: fieldBorderStyles.variants.isDisabled + } +}); + +const tokenStyles = tv({ + extend: focusRing, + base: 'inline-flex items-center rounded-full h-5 px-2 py-px mx-0.5 cursor-default whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-400/20 dark:text-blue-300 transition [-webkit-tap-highlight-color:transparent] selection:bg-transparent group-data-disabled:bg-neutral-100 group-data-disabled:text-neutral-300 dark:group-data-disabled:bg-neutral-800 dark:group-data-disabled:text-neutral-600 forced-colors:group-data-disabled:text-[GrayText]', + variants: { + isSelected: { + true: 'bg-blue-600 text-white dark:bg-blue-600 dark:text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText] forced-color-adjust-none' + } + } +}); + +export function TokenField(props: TokenFieldProps) { + return ( + + tokenFieldStyles({...renderProps, className}) + )} + /> + ); +} + +export function Token(props: TokenProps) { + return ( + + tokenStyles({...renderProps, className}) + )} + /> + ); +} From e120029953561f9373a754aff1ac7c58e6651129 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Jul 2026 10:35:57 -0700 Subject: [PATCH 03/11] Tokenize when pasting multiple segments --- .../src/tokenfield/TokenSegmentList.ts | 31 +++++++------------ .../test/tokenfield/TokenSegmentList.test.ts | 17 ++++++++++ 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/packages/react-stately/src/tokenfield/TokenSegmentList.ts b/packages/react-stately/src/tokenfield/TokenSegmentList.ts index d6ce230347c..6b2631aaf42 100644 --- a/packages/react-stately/src/tokenfield/TokenSegmentList.ts +++ b/packages/react-stately/src/tokenfield/TokenSegmentList.ts @@ -155,7 +155,7 @@ export class TokenSegmentList { } if (insert.length) { - appendSegments(newSegments, insert); + appendSegments(newSegments, insert, text => this.tokenize(text)); } let lastSegment = newSegments[newSegments.length - 1]; @@ -171,22 +171,6 @@ export class TokenSegmentList { appendSegments(newSegments, this.segments.slice(end.index + 1)); - if (insert.length > 0) { - let i = caret.index; - let seg = newSegments[i]; - if (seg?.type === 'text') { - let window = seg.text.slice(0, caret.offset); - let suffix = seg.text.slice(caret.offset); - let tokenized = this.tokenize(window); - caret.index += tokenized.length - 1; - caret.offset = tokenized[tokenized.length - 1].text.length; - if (suffix.length > 0) { - appendSegments(tokenized, [this.createTextSegment(suffix)]); - } - newSegments.splice(i, 1, ...tokenized); - } - } - let segments = this.createSegmentList(newSegments); segments.caretPosition = caret; segments.isCoalescing = coalesce; @@ -425,7 +409,8 @@ function findInText( function appendSegments( segments: TokenFieldSegment[], - insert: TokenFieldSegment[] + insert: TokenFieldSegment[], + tokenize?: (text: string) => TokenFieldSegment[] ): TokenFieldSegment[] { for (let segment of insert) { if (segment.type === 'text' && segment.text.length === 0) { @@ -434,7 +419,15 @@ function appendSegments( let last = segments[segments.length - 1]; if (last && last.type === 'text' && segment.type === 'text') { - segments[segments.length - 1] = {type: 'text', text: last.text + segment.text}; + if (tokenize) { + let tokenized = tokenize(last.text + segment.text); + segments.splice(segments.length - 1, 1, ...tokenized); + } else { + segments[segments.length - 1] = {type: 'text', text: last.text + segment.text}; + } + } else if (tokenize && segment.type === 'text') { + let tokenized = tokenize(segment.text); + segments.push(...tokenized); } else { segments.push(segment); } diff --git a/packages/react-stately/test/tokenfield/TokenSegmentList.test.ts b/packages/react-stately/test/tokenfield/TokenSegmentList.test.ts index 12bbcb47c78..09a10312d3a 100644 --- a/packages/react-stately/test/tokenfield/TokenSegmentList.test.ts +++ b/packages/react-stately/test/tokenfield/TokenSegmentList.test.ts @@ -1126,5 +1126,22 @@ describeOrSkip('TokenSegmentList', () => { expect(value).toEqual([text('@alice ')]); expect(caret).toEqual({index: 0, offset: 7}); }); + + it('tokenizes when pasting multiple segments', () => { + let list = new TokenizingSegmentList([text('')], mentionRe); + let {segments: value} = list.replaceRangeWithSegments( + {index: 0, offset: 0}, + {index: 0, offset: 0}, + [text('hello @alice '), token('@bob'), text(' world '), token('@charlie')] + ); + expect(value).toEqual([ + text('hello '), + token('@alice'), + text(' '), + token('@bob'), + text(' world '), + token('@charlie') + ]); + }); }); }); From 177150d94a33f5ebca129dd4cccbabfc15ce9485 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Jul 2026 11:30:46 -0700 Subject: [PATCH 04/11] docs updates --- .../dev/s2-docs/pages/react-aria/Autocomplete.mdx | 6 ++++++ packages/dev/s2-docs/pages/react-aria/ComboBox.mdx | 5 +++++ .../dev/s2-docs/pages/react-aria/TokenField.mdx | 13 +++++-------- packages/react-aria-components/src/TokenField.tsx | 4 ++-- 4 files changed, 18 insertions(+), 10 deletions(-) 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 78e98c2ebe2..b93bc3db183 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/TokenField.mdx b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx index 5f9d86151a8..9e8fde562a1 100644 --- a/packages/dev/s2-docs/pages/react-aria/TokenField.mdx +++ b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx @@ -4,10 +4,9 @@ export default Layout; import docs from 'docs:react-aria-components'; import '../../tailwind/tailwind.css'; -export const tags = ['input', 'tokens', 'mentions', 'tags', 'autocomplete']; +export const tags = ['input', 'token', 'mention', 'tag', 'autocomplete', 'ai', 'prompt', 'comment', 'multi-select', 'combobox', 'search']; export const version = 'alpha'; -export const relatedPages = [{'title': 'useTokenField', 'url': 'TokenField/useTokenField.html'}]; -export const description = 'Allows users to enter text with inline tokens, such as hashtags, mentions, tags, or template variables.'; +export const description = 'Allows users to enter text with inline tokens such as mentions, tags, or object references.'; # TokenField @@ -49,7 +48,7 @@ export const description = 'Allows users to enter text with inline tokens, such ## Value -`TokenField` is controlled via a `TokenSegmentList` value, which represents a sequence of text and token segments. Use the `value` and `onChange` props to manage the field state, or `defaultValue` for uncontrolled usage. +`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"; @@ -83,8 +82,6 @@ function Example() { 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. -The following example automatically tokenizes hashtags and @mentions as the user types. - ```tsx render "use client"; import {Token, TokenField} from 'vanilla-starter/TokenField'; @@ -235,7 +232,7 @@ function Example() { ## Tag field -Use a custom `TokenSegmentList` to build a tag input. The following example converts comma, space, or newline separated text into tokens. +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"; @@ -260,7 +257,7 @@ import {TagFieldSegmentList} from './TagFieldSegmentList'; ## Search -Token fields can be used to build advanced search UIs with structured query tokens. The following example suggests filters based on the current text segment. +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"; diff --git a/packages/react-aria-components/src/TokenField.tsx b/packages/react-aria-components/src/TokenField.tsx index 6d3c58dbe61..b2f2619d178 100644 --- a/packages/react-aria-components/src/TokenField.tsx +++ b/packages/react-aria-components/src/TokenField.tsx @@ -82,8 +82,8 @@ export interface TokenFieldProps } /** - * A token field allows users to enter text with inline tokens, such as hashtags, mentions, tags, or - * template variables. + * 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__*/ (forwardRef as forwardRefType)(function TokenField< T extends TokenSegmentList = TokenSegmentList From 0a8a21aaec013732f83a0103b67520540e904162 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Jul 2026 11:33:18 -0700 Subject: [PATCH 05/11] multiline -> allowsNewlines --- packages/dev/s2-docs/pages/react-aria/TokenField.mdx | 8 ++++---- .../react-aria-components/stories/TokenField.stories.tsx | 6 +++--- .../test/TokenField.browser.test.tsx | 8 ++++---- packages/react-aria/src/tokenfield/useTokenField.ts | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/TokenField.mdx b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx index 9e8fde562a1..f287c96836d 100644 --- a/packages/dev/s2-docs/pages/react-aria/TokenField.mdx +++ b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx @@ -13,7 +13,7 @@ export const description = 'Allows users to enter text with inline tokens such a {docs.exports.TokenField.description} - ```tsx render docs={docs.exports.TokenField} links={docs.links} props={['multiline', 'isDisabled', 'isReadOnly']} initialProps={{multiline: true}} type="vanilla" files={["starters/docs/src/TokenField.tsx", "starters/docs/src/TokenField.css", "packages/dev/s2-docs/pages/react-aria/TokenizingSegmentList.ts"]} + ```tsx render docs={docs.exports.TokenField} links={docs.links} props={['allowsNewlines', 'isDisabled', 'isReadOnly']} initialProps={{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'; @@ -29,7 +29,7 @@ export const description = 'Allows users to enter text with inline tokens such a ``` - ```tsx render docs={docs.exports.TokenField} links={docs.links} props={['multiline', 'isDisabled', 'isReadOnly']} initialProps={{multiline: true}} type="tailwind" files={["starters/docs/src/TokenField.tsx", "starters/docs/src/TokenField.css", "packages/dev/s2-docs/pages/react-aria/TokenizingSegmentList.ts"]} + ```tsx render docs={docs.exports.TokenField} links={docs.links} props={['allowsNewlines', 'isDisabled', 'isReadOnly']} initialProps={{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'; @@ -88,7 +88,7 @@ import {Token, TokenField} from 'vanilla-starter/TokenField'; import {TokenizingSegmentList} from './TokenizingSegmentList'; { return ( { export const Template: TokenFieldStory = () => { return ( { return ( { 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'); @@ -820,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(); @@ -840,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-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index b6aaf763e1d..8d881ad5bfd 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -48,7 +48,7 @@ export interface AriaTokenFieldProps( ): TokenFieldAria { let { role = 'textbox', - multiline = false, + allowsNewlines: multiline = false, isReadOnly = false, isDisabled = false, 'aria-label': ariaLabel, From 84e11b8637d954b2607d40aff007c0e7730c5c15 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Jul 2026 15:45:05 -0700 Subject: [PATCH 06/11] support visible label and description --- .../@react-spectrum/ai/src/PromptField.tsx | 107 ++++---- .../s2-docs/pages/react-aria/TokenField.mdx | 31 ++- .../exports/TokenField.ts | 7 +- .../react-aria-components/exports/index.ts | 4 +- .../react-aria-components/src/TokenField.tsx | 233 ++++++++++++++---- .../stories/TokenField.stories.tsx | 37 +-- .../stories/styles.global.css | 2 +- .../test/TokenField.browser.test.tsx | 6 +- .../TokenField.composition.browser.test.tsx | 10 +- .../test/TokenField.test.js | 74 ++++++ .../test/utils/tokenFieldBrowserUtils.tsx | 12 +- .../src/tokenfield/useTokenField.ts | 34 ++- starters/docs/src/Form.css | 1 + starters/docs/src/TokenField.css | 15 +- starters/docs/src/TokenField.tsx | 36 ++- starters/tailwind/src/TokenField.tsx | 39 ++- 16 files changed, 473 insertions(+), 175 deletions(-) create mode 100644 packages/react-aria-components/test/TokenField.test.js diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 968d047649d..4536ac466e2 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -44,7 +44,7 @@ import {Link} from '@react-spectrum/s2/Link'; import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Menu'; import Plus from '@react-spectrum/s2/icons/Add'; import {Popover, PopoverProps} from '@react-spectrum/s2/Popover'; -import {positionToDOMRange, Token, TokenField, TokenProps} from 'react-aria-components/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'; @@ -331,63 +331,60 @@ export function PromptTokenField(props: PromptTokenFieldProps) { return ( - { - if (e.isTrusted) { - setFocused(true); - } - }} - onBlur={e => { - if (e.isTrusted) { - setFocused(false); - } - }} - onPaste={ - acceptedAttachmentTypes - ? e => { - let clipboardData = e.clipboardData as DataTransfer; - let attachments: PromptFieldAttachment[] = []; - for (let item of clipboardData.items) { - if (matchMimeType(item.type, acceptedAttachmentTypes)) { - let file = item.getAsFile()!; - attachments.push({ - id: crypto.randomUUID(), - file, - image: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' - }); + + { + if (e.isTrusted) { + setFocused(true); + } + }} + onBlur={e => { + if (e.isTrusted) { + setFocused(false); + } + }} + onPaste={ + acceptedAttachmentTypes + ? e => { + let clipboardData = e.clipboardData as DataTransfer; + let attachments: PromptFieldAttachment[] = []; + for (let item of clipboardData.items) { + if (matchMimeType(item.type, acceptedAttachmentTypes)) { + let file = item.getAsFile()!; + attachments.push({ + id: crypto.randomUUID(), + file, + image: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' + }); + } + } + if (attachments.length > 0) { + onAddAttachments?.(attachments); + setAttachments(prev => [...prev, ...attachments]); } } - if (attachments.length > 0) { - onAddAttachments?.(attachments); - setAttachments(prev => [...prev, ...attachments]); + : undefined + } + className={renderProps => + css('&:empty::before { content: attr(data-placeholder); }') + + style({ + font: 'body', + color: { + default: baseColor('neutral'), + ':empty': { + default: 'gray-600', + forcedColors: 'GrayText' } - } - : 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})} + }, + width: 'full', + outlineStyle: 'none', + cursor: 'text' + })(renderProps) + }> + {children || (segment => {segment.text})} + + )}> {segment => {segment.text}} ``` - ```tsx render docs={docs.exports.TokenField} links={docs.links} props={['allowsNewlines', 'isDisabled', 'isReadOnly']} initialProps={{allowsNewlines: true}} type="tailwind" files={["starters/docs/src/TokenField.tsx", "starters/docs/src/TokenField.css", "packages/dev/s2-docs/pages/react-aria/TokenizingSegmentList.ts"]} + ```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'; @@ -39,8 +39,7 @@ export const description = 'Allows users to enter text with inline tokens such a defaultValue={TokenizingSegmentList.tokenize( 'This example automatically tokenizes #hashtags and @usernames in the text.', /(?<=\s|^)[#@]\S+(?=\s)/g - )} - aria-label="Message"> + )}> {segment => {segment.text}} ``` @@ -68,7 +67,7 @@ function Example() { return ( <> {/*- begin highlight -*/} - + {segment => {segment.text}} {/*- end highlight -*/} @@ -93,7 +92,7 @@ import {TokenizingSegmentList} from './TokenizingSegmentList'; 'This example automatically tokenizes #hashtags and @usernames in the text.', /(?<=\s|^)[#@]\S+(?=\s)/g )} - aria-label="Message"> + label="Message"> {segment => {segment.text}} ``` @@ -185,7 +184,7 @@ function Example() { return ( /*- begin highlight -*/ - + {segment => {segment.text}} + label="Categories"> {segment => {segment.text}} ``` @@ -339,7 +338,7 @@ function Example() { return ( - + {segment => {segment.text}} - {segment => } + ``` @@ -467,6 +468,10 @@ function Example() { +### TokenInput + + + ### Token diff --git a/packages/react-aria-components/exports/TokenField.ts b/packages/react-aria-components/exports/TokenField.ts index 425e594c07e..b2c73790052 100644 --- a/packages/react-aria-components/exports/TokenField.ts +++ b/packages/react-aria-components/exports/TokenField.ts @@ -10,12 +10,15 @@ * governing permissions and limitations under the License. */ -export {TokenField, Token} from '../src/TokenField'; +export {TokenField, TokenInput, Token} from '../src/TokenField'; export type { TokenFieldProps, TokenFieldRenderProps, + TokenInputProps, + TokenInputRenderProps, TokenProps, - TokenRenderProps + TokenRenderProps, + TokenFieldContext } from '../src/TokenField'; export {positionToDOMRange} from 'react-aria/useTokenField'; export {TokenSegmentList, Direction} from 'react-stately/useTokenFieldState'; diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index f18d1ba8b4e..5e93f6402d4 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -225,7 +225,7 @@ 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, Token} from '../src/TokenField'; +export {TokenField, TokenInput, Token, TokenFieldContext} from '../src/TokenField'; export {TokenSegmentList} from 'react-stately/useTokenFieldState'; export { UNSTABLE_Toast, @@ -499,6 +499,8 @@ export type {TextFieldProps, TextFieldRenderProps} from '../src/TextField'; export type { TokenFieldProps, TokenFieldRenderProps, + TokenInputProps, + TokenInputRenderProps, TokenProps, TokenRenderProps } from '../src/TokenField'; diff --git a/packages/react-aria-components/src/TokenField.tsx b/packages/react-aria-components/src/TokenField.tsx index b2f2619d178..c42ec506db1 100644 --- a/packages/react-aria-components/src/TokenField.tsx +++ b/packages/react-aria-components/src/TokenField.tsx @@ -13,59 +13,116 @@ 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} from '@react-types/shared'; +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, {ForwardedRef, forwardRef, HTMLAttributes, memo} from 'react'; -import {TokenSegment, TokenSegmentList, useTokenFieldState} from 'react-stately/useTokenFieldState'; +import React, { + createContext, + ForwardedRef, + forwardRef, + HTMLAttributes, + memo, + RefObject, + useContext, + useMemo, + useRef +} 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'; +import {mergeRefs} from 'react-aria/mergeRefs'; export interface TokenFieldRenderProps { /** - * Whether the token field is currently hovered with a mouse. + * 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 field is focused, either via a mouse or keyboard. + * Whether the token input is focused, either via a mouse or keyboard. * * @selector [data-focused] */ isFocused: boolean; /** - * Whether the token field is keyboard focused. + * Whether the token input is keyboard focused. * * @selector [data-focus-visible] */ isFocusVisible: boolean; /** - * Whether the token field is disabled. + * Whether the token input is disabled. * * @selector [data-disabled] */ isDisabled: boolean; /** - * Whether the token field is read only. + * Whether the token input is read only. * * @selector [data-readonly] */ isReadOnly: boolean; } -export interface TokenFieldProps - extends AriaTokenFieldProps, HoverProps, StyleRenderProps, SlotProps { +export interface TokenInputProps + extends + HoverProps, + StyleRenderProps, + SlotProps, + GlobalDOMAttributes { /** * A function that renders a token for each segment in the token field. */ @@ -76,27 +133,33 @@ export interface TokenFieldProps * 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' + * @default 'react-aria-TokenInput' */ - className?: ClassNameOrFunction; + className?: ClassNameOrFunction; } +interface TokenInputContextValue { + tokenFieldProps: HTMLAttributes; + isComposing: boolean; + 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__*/ (forwardRef as forwardRefType)(function TokenField< +export const TokenField = /*#__PURE__*/ createHideableComponent(function TokenField< T extends TokenSegmentList = TokenSegmentList ->(props: TokenFieldProps, forwardedRef: ForwardedRef) { - let { - onChange, - children, - isReadOnly = false, - isDisabled = false, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy, - 'aria-describedby': ariaDescribedBy - } = props; +>(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 { @@ -105,35 +168,103 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function ref: autocompleteRef, ...autocompleteProps } = fieldCtx ?? {}; + let inputRef = useObjectRef(autocompleteRef); - let ref = useObjectRef(forwardedRef); - let state = useTokenFieldState({ + let isDisabled = props.isDisabled || false; + let isReadOnly = props.isReadOnly || false; + + let state = useTokenFieldState({ ...props, onChange: (value: T) => { - onChange?.(value); + props.onChange?.(value); onAutocompleteChange?.(value.toString()); } }); - let {tokenFieldProps, isComposing} = useTokenField( + let {tokenFieldProps, labelProps, descriptionProps, isComposing} = useTokenField( { ...props, - role: props.role || autocompleteProps['role'] || 'textbox', - 'aria-label': ariaLabel ?? autocompleteProps['aria-label'], - 'aria-labelledby': ariaLabelledBy ?? autocompleteProps['aria-labelledby'], - 'aria-describedby': ariaDescribedBy ?? autocompleteProps['aria-describedby'] + // @ts-ignore - not a public prop, used to determine if slot is present + label, + role: props.role || autocompleteProps['role'] || 'textbox' }, state, - ref + inputRef + ); + + let renderProps = useRenderProps({ + ...props, + values: { + isDisabled, + isReadOnly + }, + defaultClassName: 'react-aria-TokenField' + }); + + let DOMProps = filterDOMProps(props, {global: true}); + + return ( + + , + ref: inputRef + } + ] + ]}> + {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, + isComposing, + 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(props); + let {isHovered, hoverProps} = useHover(domProps); let {isFocused, isFocusVisible, focusProps} = useFocusRing(); let renderProps = useRenderProps({ - ...props, - children: undefined, - defaultClassName: 'react-aria-TokenField', + ...domProps, + defaultClassName: 'react-aria-TokenInput', values: { isHovered, isFocused, @@ -143,24 +274,24 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function } }); - let DOMProps = filterDOMProps(props, {global: true}); + let DOMProps = filterDOMProps(domProps, {global: true}); return ( -
+ autocompleteProps )} - ref={mergeRefs(ref, autocompleteRef as any)} + ref={ref} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} data-disabled={isDisabled || undefined} data-readonly={isReadOnly || undefined} - style={{...renderProps.style, ...tokenFieldProps.style}}> + style={{...renderProps.style, ...tokenFieldProps?.style}}> {state.value.segments.map((v, i) => { switch (v.type) { @@ -182,7 +313,7 @@ export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function {/* Force cursor to the next line if the last segment ends with a newline. */} {state.value.segments.at(-1)?.text.endsWith('\n') &&
}
-
+ ); }); @@ -201,7 +332,8 @@ export interface TokenRenderProps { isDisabled: boolean; } -export interface TokenProps extends RenderProps { +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. @@ -218,6 +350,7 @@ export const Token = forwardRef(function Token( props: TokenProps, ref: ForwardedRef ) { + let {isDisabled} = useContext(TokenInputContext)!; let objectRef = useObjectRef(ref); let {tokenProps, isSelected} = useToken(props, {}, objectRef); @@ -226,22 +359,24 @@ export const Token = forwardRef(function Token( defaultClassName: 'react-aria-Token', values: { isSelected, - isDisabled: false // TODO + isDisabled } }); + let DOMProps = filterDOMProps(props, {global: true}); + return ( - {renderProps.children} - + ); }); diff --git a/packages/react-aria-components/stories/TokenField.stories.tsx b/packages/react-aria-components/stories/TokenField.stories.tsx index e970d96f966..1a5ca30b3c7 100644 --- a/packages/react-aria-components/stories/TokenField.stories.tsx +++ b/packages/react-aria-components/stories/TokenField.stories.tsx @@ -27,7 +27,7 @@ import {Header, Menu, MenuItem, MenuSection} from 'vanilla-starter/Menu'; import {Key} from '@react-types/shared'; import {Popover} from 'vanilla-starter/Popover'; import {positionToDOMRange} from 'react-aria/useTokenField'; -import {Token, TokenField} from '../src/TokenField'; +import {Token, TokenField, TokenInput} from '../src/TokenField'; import 'vanilla-starter/TagGroup.css'; import {Text} from 'react-aria-components/Text'; @@ -95,9 +95,10 @@ export const AutoTokenize: TokenFieldStory = () => { defaultValue={TokenizingSegmentList.tokenize( 'This example automatically tokenizes #hashtags and @usernames in the text.', /(?<=\s|^)[#@]\S+(?=\s)/g - )} - aria-label="Message"> - {segment => {segment.text}} + )}> + + {segment => {segment.text}} + Type #hashtags or @usernames to create tokens.
); }; @@ -109,9 +110,9 @@ export const Template: TokenFieldStory = () => { defaultValue={TokenizingSegmentList.tokenize( "Hello {{firstName}}, it's nice to meet you!", /(?<=\s|^)\{\{.+?\}\}/g - )} - aria-label="Message"> - {segment => {segment.text}} + )}> + + {segment => {segment.text}}
); }; @@ -197,8 +198,9 @@ export const WithAutocomplete: TokenFieldStory = () => { return ( - - {segment => {segment.text}} + + + {segment => {segment.text}} { {type: 'token', text: 'Marketing'}, {type: 'token', text: 'Sales'} ]) - } - aria-label="Categories"> - {segment => {segment.text}} + }> + + {segment => {segment.text}} ); }; @@ -321,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-aria-components/stories/styles.global.css b/packages/react-aria-components/stories/styles.global.css index b60085e25c6..1c219e67232 100644 --- a/packages/react-aria-components/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'; -.react-aria-TokenField { +.react-aria-TokenInput { padding: 4px 8px; outline: none; border-radius: 8px; diff --git a/packages/react-aria-components/test/TokenField.browser.test.tsx b/packages/react-aria-components/test/TokenField.browser.test.tsx index ab4935c896c..e8ce6b990f4 100644 --- a/packages/react-aria-components/test/TokenField.browser.test.tsx +++ b/packages/react-aria-components/test/TokenField.browser.test.tsx @@ -44,7 +44,7 @@ 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 {Token, TokenField, TokenInput} from '../src/TokenField'; const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json'; @@ -78,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(); @@ -114,7 +114,7 @@ describeOrSkip('TokenField browser interactions', () => { onChange={e => setValue(segments(text(e.target.value)))} /> - {segment => {segment.text}} + {segment => {segment.text}} ); diff --git a/packages/react-aria-components/test/TokenField.composition.browser.test.tsx b/packages/react-aria-components/test/TokenField.composition.browser.test.tsx index 3a56cd1a370..12477ba965e 100644 --- a/packages/react-aria-components/test/TokenField.composition.browser.test.tsx +++ b/packages/react-aria-components/test/TokenField.composition.browser.test.tsx @@ -41,7 +41,7 @@ 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 {Token, TokenField, TokenInput} from '../src/TokenField'; import {TokenSegmentList} from 'react-stately/useTokenFieldState'; declare module 'vitest/browser' { @@ -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..f076bee7491 --- /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 {render} from '@react-spectrum/test-utils-internal'; +import {Label} from '../src/Label'; +import React from 'react'; +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-aria-components/test/utils/tokenFieldBrowserUtils.tsx b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx index 53709e2e91b..0f9daa385cb 100644 --- a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx +++ b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx @@ -16,7 +16,13 @@ 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 {Token, TokenField, type TokenFieldProps} from '../../src/TokenField'; +import { + Token, + TokenField, + TokenInput, + type TokenFieldProps, + type TokenInputProps +} from '../../src/TokenField'; export function text(s: string): TokenFieldSegment { return {type: 'text', text: s}; @@ -176,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); @@ -190,7 +198,7 @@ function ControlledTokenField({ }, [value, valueRef]); return ( - {segment => {segment.text}} + {children} ); } diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 8d881ad5bfd..646b45326b2 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -11,7 +11,7 @@ */ import {announce} from '../live-announcer/LiveAnnouncer'; -import {AriaLabelingProps, FocusableProps} from '@react-types/shared'; +import {AriaLabelingProps, DOMAttributes, FocusableProps} from '@react-types/shared'; import { ClipboardEventHandler, HTMLAttributes, @@ -33,7 +33,9 @@ import {getActiveElement} from '../utils/shadowdom/DOMFunctions'; import {getOwnerDocument} from '../utils/domHelpers'; import {isMac} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; +import {setInteractionModality} from '../interactions/useFocusVisible'; import {useEvent} from '../utils/useEvent'; +import {useField} from '../label/useField'; import {useFocusable} from '../interactions/useFocusable'; import {useKeyboard} from '../interactions/useKeyboard'; import {useLayoutEffect} from '../utils/useLayoutEffect'; @@ -75,7 +77,13 @@ export interface AriaTokenFieldProps; + /** Props for the text field's visible label element, if any. */ + labelProps: DOMAttributes; + /** Props for the text field's description element, if any. */ + descriptionProps: DOMAttributes; + /** Whether the input is composing. */ isComposing: boolean; } @@ -91,9 +99,6 @@ export function useTokenField( allowsNewlines: multiline = false, isReadOnly = false, isDisabled = false, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy, - 'aria-describedby': ariaDescribedBy, 'aria-details': ariaDetails } = props; @@ -535,9 +540,25 @@ export function useTokenField( }); let {focusableProps} = useFocusable(props, ref); + let {labelProps, fieldProps, descriptionProps} = useField({ + ...props, + labelElementType: 'span' + }); return { - tokenFieldProps: mergeProps(focusableProps, keyboardProps, { + labelProps: { + ...labelProps, + onClick: () => { + if (!props.isDisabled) { + ref.current?.focus(); + + // Show the focus ring so the user knows where focus went + setInteractionModality('keyboard'); + } + } + }, + descriptionProps, + tokenFieldProps: mergeProps(focusableProps, keyboardProps, fieldProps, { onPaste: props.onPaste, onCopy: props.onCopy, onCut: props.onCut, @@ -545,9 +566,6 @@ export function useTokenField( suppressContentEditableWarning: true, role, 'aria-multiline': multiline, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy, - 'aria-describedby': ariaDescribedBy, 'aria-details': ariaDetails, 'aria-readonly': isReadOnly, 'aria-disabled': isDisabled, diff --git a/starters/docs/src/Form.css b/starters/docs/src/Form.css index 4ddcfccdec0..5ded91f8319 100644 --- a/starters/docs/src/Form.css +++ b/starters/docs/src/Form.css @@ -38,6 +38,7 @@ color: var(--text-color); margin-bottom: var(--spacing-2); font-weight: 500; + cursor: default; } .react-aria-FieldError { diff --git a/starters/docs/src/TokenField.css b/starters/docs/src/TokenField.css index 13de427585b..dbf2a2b0e72 100644 --- a/starters/docs/src/TokenField.css +++ b/starters/docs/src/TokenField.css @@ -2,14 +2,19 @@ @import './utilities.css'; .react-aria-TokenField { - padding: var(--spacing-2) var(--spacing-3); + display: flex; + flex-direction: column; + width: 100%; +} + +.react-aria-TokenInput { + padding: calc(var(--spacing) * 1.5) calc(var(--spacing) * 2.5); outline: none; border-radius: var(--radius); outline: none; - width: 100%; box-sizing: border-box; font: var(--font-size) system-ui; - line-height: 1.2em; + line-height: 1.5em; color: var(--text-color); &:empty::before { @@ -19,7 +24,7 @@ &[aria-multiline='true'] { min-height: 100px; - padding: var(--spacing-3); + padding: calc(var(--spacing) * 2.5) var(--spacing-3); } &[data-focused] { @@ -53,7 +58,7 @@ background-color: transparent; } - [data-disabled] & { + &[data-disabled] { background-color: var(--gray-200); color: var(--text-color-disabled); } diff --git a/starters/docs/src/TokenField.tsx b/starters/docs/src/TokenField.tsx index 0715ad5de8b..179821fac57 100644 --- a/starters/docs/src/TokenField.tsx +++ b/starters/docs/src/TokenField.tsx @@ -1,23 +1,45 @@ 'use client'; import { TokenField as AriaTokenField, + TokenInput as AriaTokenInput, Token as AriaToken, TokenFieldProps as AriaTokenFieldProps, + TokenInputProps, TokenProps } from 'react-aria-components/TokenField'; +import {Label, Description} from './Form'; import './TokenField.css'; +import type React from 'react'; -export interface TokenFieldProps extends AriaTokenFieldProps { +export interface TokenFieldProps extends Omit { + label?: string; + description?: string; placeholder?: string; + inputRef?: React.Ref; + children: TokenInputProps['children']; } -export function TokenField(props: TokenFieldProps) { +export function TokenField({ + label, + description, + placeholder, + inputRef, + style, + children, + ...props +}: TokenFieldProps) { return ( - + + {label && } + + {children} + + {description && {description}} + ); } diff --git a/starters/tailwind/src/TokenField.tsx b/starters/tailwind/src/TokenField.tsx index 9508c912e4f..38f62d648aa 100644 --- a/starters/tailwind/src/TokenField.tsx +++ b/starters/tailwind/src/TokenField.tsx @@ -2,16 +2,22 @@ import React from 'react'; import { TokenField as AriaTokenField, + TokenInput as AriaTokenInput, Token as AriaToken, - type TokenFieldProps, + type TokenFieldProps as AriaTokenFieldProps, + type TokenInputProps, type TokenProps } from 'react-aria-components/TokenField'; import {composeRenderProps} from 'react-aria-components/composeRenderProps'; import {tv} from 'tailwind-variants'; -import {fieldBorderStyles} from './Field'; -import {focusRing} from './utils'; +import {Description, Label, fieldBorderStyles} from './Field'; +import {composeTailwindRenderProps, focusRing} from './utils'; const tokenFieldStyles = tv({ + base: 'flex flex-col gap-1 font-sans' +}); + +const tokenInputStyles = tv({ extend: focusRing, base: 'group border-1 rounded-lg font-sans text-sm py-2 px-3 [&[aria-multiline=true]]:min-h-[100px] box-border w-full transition bg-white dark:bg-neutral-900 forced-colors:bg-[Field] text-neutral-800 dark:text-neutral-200 outline-0 disabled:text-neutral-200 dark:disabled:text-neutral-600 disabled:select-none [-webkit-tap-highlight-color:transparent]', variants: { @@ -30,14 +36,31 @@ const tokenStyles = tv({ } }); -export function TokenField(props: TokenFieldProps) { +export interface TokenFieldProps extends Omit { + label?: string; + description?: string; + inputRef?: React.Ref; + children: TokenInputProps['children']; +} + +export function TokenField({ + label, + description, + className, + inputRef, + children, + ...props +}: TokenFieldProps) { return ( - tokenFieldStyles({...renderProps, className}) - )} - /> + className={composeTailwindRenderProps(className, tokenFieldStyles())}> + {label && } + + {children} + + {description && {description}} + ); } From 99fba3f87f2b4ba7f6eac0923a1ab96e6b801c60 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Jul 2026 19:51:37 -0700 Subject: [PATCH 07/11] lint --- .../@react-spectrum/ai/src/PromptField.tsx | 80 +++++++++++-------- .../react-aria-components/src/TokenField.tsx | 7 +- .../test/TokenField.test.js | 2 +- .../test/utils/tokenFieldBrowserUtils.tsx | 2 +- 4 files changed, 51 insertions(+), 40 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 4536ac466e2..4685fa2ddff 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -44,7 +44,13 @@ import {Link} from '@react-spectrum/s2/Link'; import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Menu'; import Plus from '@react-spectrum/s2/icons/Add'; import {Popover, PopoverProps} from '@react-spectrum/s2/Popover'; -import {positionToDOMRange, Token, TokenField, TokenInput, TokenProps} from 'react-aria-components/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'; @@ -331,42 +337,48 @@ export function PromptTokenField(props: PromptTokenFieldProps) { return ( - + { + if (e.isTrusted) { + setFocused(true); + } + }} + onBlur={e => { + if (e.isTrusted) { + setFocused(false); + } + }} + onPaste={ + acceptedAttachmentTypes + ? e => { + let clipboardData = e.clipboardData as DataTransfer; + let attachments: PromptFieldAttachment[] = []; + for (let item of clipboardData.items) { + if (matchMimeType(item.type, acceptedAttachmentTypes)) { + let file = item.getAsFile()!; + attachments.push({ + id: crypto.randomUUID(), + file, + image: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' + }); + } + } + if (attachments.length > 0) { + onAddAttachments?.(attachments); + setAttachments(prev => [...prev, ...attachments]); + } + } + : undefined + }> { - if (e.isTrusted) { - setFocused(true); - } - }} - onBlur={e => { - if (e.isTrusted) { - setFocused(false); - } - }} - onPaste={ - acceptedAttachmentTypes - ? e => { - let clipboardData = e.clipboardData as DataTransfer; - let attachments: PromptFieldAttachment[] = []; - for (let item of clipboardData.items) { - if (matchMimeType(item.type, acceptedAttachmentTypes)) { - let file = item.getAsFile()!; - attachments.push({ - id: crypto.randomUUID(), - file, - image: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' - }); - } - } - if (attachments.length > 0) { - onAddAttachments?.(attachments); - setAttachments(prev => [...prev, ...attachments]); - } - } - : undefined - } className={renderProps => css('&:empty::before { content: attr(data-placeholder); }') + style({ diff --git a/packages/react-aria-components/src/TokenField.tsx b/packages/react-aria-components/src/TokenField.tsx index c42ec506db1..60449acc5e6 100644 --- a/packages/react-aria-components/src/TokenField.tsx +++ b/packages/react-aria-components/src/TokenField.tsx @@ -31,6 +31,7 @@ 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, @@ -39,8 +40,7 @@ import React, { memo, RefObject, useContext, - useMemo, - useRef + useMemo } from 'react'; import {TextContext} from './Text'; import { @@ -52,7 +52,6 @@ import { import {useFocusRing} from 'react-aria/useFocusRing'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useToken, useTokenField} from 'react-aria/useTokenField'; -import {mergeRefs} from 'react-aria/mergeRefs'; export interface TokenFieldRenderProps { /** @@ -231,7 +230,7 @@ export const TokenField = /*#__PURE__*/ createHideableComponent(function TokenFi isDisabled, isReadOnly, autocompleteProps: autocompleteProps as HTMLAttributes, - ref: inputRef + ref: inputRef as RefObject } ] ]}> diff --git a/packages/react-aria-components/test/TokenField.test.js b/packages/react-aria-components/test/TokenField.test.js index f076bee7491..bb87d529423 100644 --- a/packages/react-aria-components/test/TokenField.test.js +++ b/packages/react-aria-components/test/TokenField.test.js @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ -import {render} from '@react-spectrum/test-utils-internal'; 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'; diff --git a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx index 0f9daa385cb..65b21095d43 100644 --- a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx +++ b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx @@ -19,8 +19,8 @@ import {render} from 'vitest-browser-react'; import { Token, TokenField, - TokenInput, type TokenFieldProps, + TokenInput, type TokenInputProps } from '../../src/TokenField'; From d20a0745191c02195b0471ab7a74d939a9676a62 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Jul 2026 19:54:19 -0700 Subject: [PATCH 08/11] Render combobox label as span when not an input element --- .../react-aria-components/src/ComboBox.tsx | 16 +++++++++++---- .../react-aria/src/combobox/useComboBox.ts | 20 ++++++++++++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) 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/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