-
- {segment => {segment.text}}
+
+
+ {segment => {segment.text}}
+
diff --git a/packages/@react-spectrum/ai/stories/styles.global.css b/packages/react-aria-components/stories/styles.global.css
similarity index 86%
rename from packages/@react-spectrum/ai/stories/styles.global.css
rename to packages/react-aria-components/stories/styles.global.css
index 665027e5685..1c219e67232 100644
--- a/packages/@react-spectrum/ai/stories/styles.global.css
+++ b/packages/react-aria-components/stories/styles.global.css
@@ -1,7 +1,7 @@
-@import '../../../../starters/docs/src/theme.css';
-@import '../../../../starters/docs/src/utilities.css';
+@import '../../../starters/docs/src/theme.css';
+@import '../../../starters/docs/src/utilities.css';
-.react-aria-TokenField {
+.react-aria-TokenInput {
padding: 4px 8px;
outline: none;
border-radius: 8px;
diff --git a/packages/@react-spectrum/ai/test/TokenField.browser.test.tsx b/packages/react-aria-components/test/TokenField.browser.test.tsx
similarity index 99%
rename from packages/@react-spectrum/ai/test/TokenField.browser.test.tsx
rename to packages/react-aria-components/test/TokenField.browser.test.tsx
index 34a70b33408..e8ce6b990f4 100644
--- a/packages/@react-spectrum/ai/test/TokenField.browser.test.tsx
+++ b/packages/react-aria-components/test/TokenField.browser.test.tsx
@@ -39,12 +39,14 @@ import {
wordDeleteModKey,
wordNavModKey
} from './utils/tokenFieldBrowserUtils';
-import {CLIPBOARD_MIME_TYPE, Token, TokenField} from '../src/TokenField';
import {commands, userEvent} from 'vitest/browser';
import {describe, expect, it} from 'vitest';
import {isFirefox, isWebKit} from 'react-aria/private/utils/platform';
import React from 'react';
import {render} from 'vitest-browser-react';
+import {Token, TokenField, TokenInput} from '../src/TokenField';
+
+const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json';
declare module 'vitest/browser' {
interface BrowserCommands {
@@ -76,7 +78,7 @@ describeOrSkip('TokenField browser interactions', () => {
it('should render textbox and tokens', async () => {
let screen = await render(
- {segment => {segment.text}}
+ {segment => {segment.text}}
);
await expect.element(screen.getByRole('textbox', {name: 'Message'})).toBeInTheDocument();
@@ -112,7 +114,7 @@ describeOrSkip('TokenField browser interactions', () => {
onChange={e => setValue(segments(text(e.target.value)))}
/>
- {segment => {segment.text}}
+ {segment => {segment.text}}
>
);
@@ -804,7 +806,7 @@ describeOrSkip('TokenField browser interactions', () => {
it('inserts a newline on Enter in a multiline field', async () => {
let {textbox, getValue} = await renderControlledTokenField(segments(text('')), {
- multiline: true
+ allowsNewlines: true
});
await focusField(textbox);
await userEvent.type(textbox, 'ab');
@@ -818,7 +820,7 @@ describeOrSkip('TokenField browser interactions', () => {
return;
}
// Put multiline text on the clipboard by copying it from a source field.
- let source = await renderControlledTokenField(segments(text('a\nb')), {multiline: true});
+ let source = await renderControlledTokenField(segments(text('a\nb')), {allowsNewlines: true});
let target = await renderControlledTokenField(segments(text('')));
let mod = modKey();
await commands.lockClipboard();
@@ -838,8 +840,8 @@ describeOrSkip('TokenField browser interactions', () => {
if (clipboardRoundTripUnsupported()) {
return;
}
- let source = await renderControlledTokenField(segments(text('a\nb')), {multiline: true});
- let target = await renderControlledTokenField(segments(text('')), {multiline: true});
+ let source = await renderControlledTokenField(segments(text('a\nb')), {allowsNewlines: true});
+ let target = await renderControlledTokenField(segments(text('')), {allowsNewlines: true});
let mod = modKey();
await commands.lockClipboard();
try {
diff --git a/packages/@react-spectrum/ai/test/TokenField.composition.browser.test.tsx b/packages/react-aria-components/test/TokenField.composition.browser.test.tsx
similarity index 97%
rename from packages/@react-spectrum/ai/test/TokenField.composition.browser.test.tsx
rename to packages/react-aria-components/test/TokenField.composition.browser.test.tsx
index a7167a73987..12477ba965e 100644
--- a/packages/@react-spectrum/ai/test/TokenField.composition.browser.test.tsx
+++ b/packages/react-aria-components/test/TokenField.composition.browser.test.tsx
@@ -41,8 +41,8 @@ import {describe, expect, it} from 'vitest';
import {isFirefox, isWebKit} from 'react-aria/private/utils/platform';
import React from 'react';
import {render} from 'vitest-browser-react';
-import {Token, TokenField} from '../src/TokenField';
-import {TokenSegmentList} from '../src/TokenSegmentList';
+import {Token, TokenField, TokenInput} from '../src/TokenField';
+import {TokenSegmentList} from 'react-stately/useTokenFieldState';
declare module 'vitest/browser' {
interface BrowserCommands {
@@ -217,7 +217,7 @@ describeOrSkip('TokenField IME composition (Android)', () => {
forceRender = () => setTick(t => t + 1);
return (
- {segment => {segment.text}}
+ {segment => {segment.text}}
);
}
@@ -322,7 +322,7 @@ describeOrSkip('TokenField IME composition (Android)', () => {
setValue(v);
}}
aria-label="onchange-field">
- {segment => {segment.text}}
+ {segment => {segment.text}}
);
}
@@ -357,7 +357,7 @@ describeOrSkip('TokenField IME composition (Android)', () => {
let [value, setValue] = React.useState(() => new TokenSegmentList([token('A'), text('')]));
return (
- {segment => {segment.text}}
+ {segment => {segment.text}}
);
}
@@ -394,7 +394,7 @@ describeOrSkip('TokenField IME composition (Android)', () => {
setValueExternal = setValue;
return (
- {segment => {segment.text}}
+ {segment => {segment.text}}
);
}
diff --git a/packages/react-aria-components/test/TokenField.test.js b/packages/react-aria-components/test/TokenField.test.js
new file mode 100644
index 00000000000..bb87d529423
--- /dev/null
+++ b/packages/react-aria-components/test/TokenField.test.js
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2026 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import {Label} from '../src/Label';
+import React from 'react';
+import {render} from '@react-spectrum/test-utils-internal';
+import {Text} from '../src/Text';
+import {Token, TokenField, TokenInput} from '../src/TokenField';
+import {TokenSegmentList} from 'react-stately/useTokenFieldState';
+
+let TestTokenField = props => (
+
+
+ {segment => {segment.text}}
+ Description
+
+);
+
+describe('TokenField', () => {
+ describe('labeling', () => {
+ it('should link the input to the label via aria-labelledby', () => {
+ let {getByRole} = render(
);
+
+ let input = getByRole('textbox');
+ expect(input.closest('.react-aria-TokenField')).toHaveAttribute('data-foo', 'bar');
+ expect(input).toHaveClass('react-aria-TokenInput');
+
+ expect(input).toHaveAttribute('aria-labelledby');
+ let label = document.getElementById(input.getAttribute('aria-labelledby'));
+ expect(label).toHaveAttribute('class', 'react-aria-Label');
+ expect(label).toHaveTextContent('Test');
+ });
+
+ it('should link the input to the description via aria-describedby', () => {
+ let {getByRole} = render(
);
+
+ let input = getByRole('textbox');
+ expect(input).toHaveAttribute('aria-describedby');
+ expect(
+ input
+ .getAttribute('aria-describedby')
+ .split(' ')
+ .map(id => document.getElementById(id).textContent)
+ .join(' ')
+ ).toBe('Description');
+ });
+
+ it('should support aria-label when no visible label is provided', () => {
+ let {getByRole} = render(
+
+ {segment => {segment.text}}
+
+ );
+
+ let input = getByRole('textbox');
+ expect(input).toHaveAttribute('aria-label', 'Message');
+ expect(input).not.toHaveAttribute('aria-labelledby');
+ });
+ });
+});
diff --git a/packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx
similarity index 94%
rename from packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx
rename to packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx
index 80f9e2b25f7..65b21095d43 100644
--- a/packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx
+++ b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx
@@ -11,17 +11,18 @@
*/
import {expect} from 'vitest';
+import {getSelection, setSelection} from '../../../react-aria/src/tokenfield/useTokenField';
+import {type Locator, userEvent} from 'vitest/browser';
+import {Position, TokenFieldSegment, TokenSegmentList} from 'react-stately/useTokenFieldState';
+import React, {useEffect, useState} from 'react';
+import {render} from 'vitest-browser-react';
import {
- getSelection,
- setSelection,
Token,
TokenField,
- type TokenFieldProps
+ type TokenFieldProps,
+ TokenInput,
+ type TokenInputProps
} from '../../src/TokenField';
-import {type Locator, userEvent} from 'vitest/browser';
-import {Position, TokenFieldSegment, TokenSegmentList} from '../../src/TokenSegmentList';
-import React, {useEffect, useState} from 'react';
-import {render} from 'vitest-browser-react';
export function text(s: string): TokenFieldSegment {
return {type: 'text', text: s};
@@ -181,12 +182,14 @@ interface ControlledProps extends Omit<
> {
initial: TokenSegmentList;
valueRef: React.MutableRefObject
;
+ children?: TokenInputProps['children'];
}
function ControlledTokenField({
initial,
valueRef,
'aria-label': ariaLabel = 'Message',
+ children = segment => {segment.text},
...props
}: ControlledProps) {
let [value, setValue] = useState(initial);
@@ -195,7 +198,7 @@ function ControlledTokenField({
}, [value, valueRef]);
return (
- {segment => {segment.text}}
+ {children}
);
}
diff --git a/packages/react-aria/exports/useTokenField.ts b/packages/react-aria/exports/useTokenField.ts
new file mode 100644
index 00000000000..15b0091b6d2
--- /dev/null
+++ b/packages/react-aria/exports/useTokenField.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2026 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing
+ */
+
+export {useTokenField, positionToDOMRange} from '../src/tokenfield/useTokenField';
+export {useToken} from '../src/tokenfield/useToken';
+export type {TokenFieldAria, AriaTokenFieldProps} from '../src/tokenfield/useTokenField';
+export type {TokenAria, TokenProps} from '../src/tokenfield/useToken';
diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts
index a1e3444817c..75c693d1a88 100644
--- a/packages/react-aria/src/combobox/useComboBox.ts
+++ b/packages/react-aria/src/combobox/useComboBox.ts
@@ -30,6 +30,7 @@ import {chain} from '../utils/chain';
import {ComboBoxProps, ComboBoxState, SelectionMode} from 'react-stately/useComboBoxState';
import {dispatchVirtualFocus} from '../focus/virtualFocus';
import {
+ ElementType,
FocusEvent,
InputHTMLAttributes,
TouchEvent,
@@ -48,6 +49,7 @@ import {isAppleDevice} from '../utils/platform';
import {ListKeyboardDelegate} from '../selection/ListKeyboardDelegate';
import {mergeProps} from '../utils/mergeProps';
import {privateValidationStateProp} from 'react-stately/private/form/useFormValidationState';
+import {setInteractionModality} from '../interactions/useFocusVisible';
import {useEvent} from '../utils/useEvent';
import {useFormReset} from '../utils/useFormReset';
import {useId} from '../utils/useId';
@@ -78,6 +80,12 @@ export interface AriaComboBoxOptions exte
listBoxRef: RefObject;
/** The ref for the optional list box popup trigger button. */
buttonRef?: RefObject;
+ /**
+ * The HTML element used to render the label, e.g. 'label', or 'span'.
+ *
+ * @default 'label'
+ */
+ labelElementType?: ElementType;
/** An optional keyboard delegate implementation, to override the default. */
keyboardDelegate?: KeyboardDelegate;
/**
@@ -476,7 +484,17 @@ export function useComboBox(
);
return {
- labelProps,
+ labelProps: {
+ ...labelProps,
+ // Focus the input in case the label is not a native