From 8d9c50a1b42e89b20dd07be8c5c26e0524178ae2 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 28 Jul 2026 16:08:27 -0700 Subject: [PATCH 1/9] chore: export various props from mono package and narrow types --- packages/@react-spectrum/ai/exports/index.ts | 5 +- .../@react-spectrum/ai/src/AttachmentList.tsx | 5 +- .../@react-spectrum/ai/src/PromptField.tsx | 69 +++++++++++++++++-- packages/react-aria/exports/index.ts | 5 ++ packages/react-stately/exports/index.ts | 2 + 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index cacb63c5b52..c261312900b 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -47,7 +47,10 @@ export type { PromptTokenFieldPopoverProps, PromptFieldToolbarProps, InsertMenuItemProps, - PromptFieldVoiceButtonProps + PromptFieldVoiceButtonProps, + InsertTokenMenuItemProps, + InsertTextMenuItemProps, + CommandMenuItemProps } from '../src/PromptField'; export type {MessageFeedbackProps} from '../src/MessageFeedback'; export type {MessageSourceProps, SourceListProps, SourceListItemProps} from '../src/MessageSource'; diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 9ccb19862ce..c67d6c9945e 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -181,7 +181,10 @@ export const AttachmentList = (forwardRef as forwardRefType)(function Attachment export interface AttachmentProps extends - Omit, + Omit< + CardProps, + 'styles' | 'UNSAFE_className' | 'UNSAFE_style' | 'allowsArrowNavigation' | 'focusMode' + >, AriaLabelingProps, Pick { /** The children of the Attachment. */ diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 5f63de843c8..709e99dc67c 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -479,7 +479,7 @@ export function PromptTokenField(props: PromptTokenFieldProps) { ); } -export interface PromptTokenFieldPopoverProps extends PopoverProps { +export interface PromptTokenFieldPopoverProps extends Omit { filterAnchor?: Position | null; items?: React.ReactNode[] | null | Promise; isFocused?: boolean; @@ -800,27 +800,84 @@ function useInsertPromptSegment(buildSegments: (item: any) => TokenFieldSegment[ }; } -export function InsertTokenMenuItem(props: MenuItemProps) { +export interface InsertTokenMenuItemProps extends Omit< + MenuItemProps, + | 'UNSAFE_className' + | 'UNSAFE_style' + | 'download' + | 'href' + | 'hrefLang' + | 'ping' + | 'referrerPolicy' + | 'rel' + | 'routerOptions' + | 'target' +> {} + +export function InsertTokenMenuItem(props: InsertTokenMenuItemProps) { let insert = useInsertPromptSegment(item => [ {type: 'token', text: 'command' in item ? item.command : item.title, value: item}, {type: 'text', text: ' '} ]); - return insert(props.value)} />; + return ( + { + insert(props.value); + props.onAction?.(); + }} + /> + ); } -export function InsertTextMenuItem(props: MenuItemProps) { +export interface InsertTextMenuItemProps extends Omit< + MenuItemProps, + | 'UNSAFE_className' + | 'UNSAFE_style' + | 'download' + | 'href' + | 'hrefLang' + | 'ping' + | 'referrerPolicy' + | 'rel' + | 'routerOptions' + | 'target' +> {} + +export function InsertTextMenuItem(props: InsertTextMenuItemProps) { let insert = useInsertPromptSegment(item => [ {type: 'text', text: `${'command' in item ? item.command : item.title} `} ]); - return insert(props.value)} />; + return ( + { + insert(props.value); + props.onAction?.(); + }} + /> + ); } +export interface CommandMenuItemProps extends Omit< + MenuItemProps, + | 'UNSAFE_className' + | 'UNSAFE_style' + | 'download' + | 'href' + | 'hrefLang' + | 'ping' + | 'referrerPolicy' + | 'rel' + | 'routerOptions' + | 'target' +> {} // specifically for menu items that only trigger a callback in the autocomplete menu // since they dont end up inserting a token or text, we need to clear the partial text that the user used // to filter the menu -export function CommandMenuItem(props: MenuItemProps) { +export function CommandMenuItem(props: CommandMenuItemProps) { let insert = useInsertPromptSegment(() => []); return ( Date: Wed, 29 Jul 2026 14:07:36 -0700 Subject: [PATCH 2/9] audit updates --- packages/@react-spectrum/ai/package.json | 1 - .../@react-spectrum/ai/src/PromptField.tsx | 25 +++++++++++-------- .../@react-spectrum/ai/src/ResponseStatus.tsx | 2 +- .../pages/react-aria/PreviewTrigger.mdx | 8 +++--- .../dev/s2-docs/pages/react-aria/Tooltip.mdx | 2 +- .../exports/PreviewTrigger.ts | 18 +++++++++++++ .../exports/TokenField.ts | 2 +- .../src/PreviewTrigger.tsx | 12 +++++++++ .../stories/TokenField.stories.tsx | 6 ++--- .../test/utils/tokenFieldBrowserUtils.tsx | 4 +-- packages/react-aria/exports/index.ts | 7 +++++- packages/react-aria/exports/useTokenField.ts | 2 +- .../react-aria/src/tokenfield/useToken.ts | 6 +++++ .../src/tokenfield/useTokenField.ts | 21 ++++++++++++---- packages/react-stately/exports/index.ts | 8 ++++++ .../src/tokenfield/useTokenFieldState.ts | 4 +++ yarn.lock | 1 - 17 files changed, 97 insertions(+), 32 deletions(-) create mode 100644 packages/react-aria-components/exports/PreviewTrigger.ts diff --git a/packages/@react-spectrum/ai/package.json b/packages/@react-spectrum/ai/package.json index fb09f74a77d..4d32a79c534 100644 --- a/packages/@react-spectrum/ai/package.json +++ b/packages/@react-spectrum/ai/package.json @@ -47,7 +47,6 @@ "dependencies": { "@internationalized/date": "^3.12.2", "@internationalized/number": "^3.6.7", - "@react-aria/utils": "^3.8.0", "@react-spectrum/s2": "1.5.1", "@react-types/shared": "^3.36.0", "react-aria": "3.50.0", diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 709e99dc67c..4a9d93a7681 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -56,25 +56,26 @@ import Microphone from '@react-spectrum/s2/icons/Microphone'; import {PixelLoader} from './loader/react'; import Plus from '@react-spectrum/s2/icons/Add'; import {Popover, PopoverProps} from '@react-spectrum/s2/Popover'; +import {PromptFieldContainer} from './PromptFieldContainer'; +import {PromptFocusContext} from './Chat'; +import Send from '@react-spectrum/s2/icons/ArrowUpSend'; +import {setTokenFieldSelection} from 'react-aria/useTokenField'; +import Stop from '@react-spectrum/s2/icons/StopProcessing'; +import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; import { - positionToDOMRange, Token, TokenField, + tokenFieldPositionToDOMRange, TokenInput, TokenProps } from 'react-aria-components/TokenField'; -import {PromptFieldContainer} from './PromptFieldContainer'; -import {PromptFocusContext} from './Chat'; -import Send from '@react-spectrum/s2/icons/ArrowUpSend'; -import {setSelection} from 'react-aria/useTokenField'; -import Stop from '@react-spectrum/s2/icons/StopProcessing'; -import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; import {Tooltip, TooltipTrigger} from '@react-spectrum/s2/Tooltip'; import {useControlledState} from 'react-stately/useControlledState'; import {useDOMRef} from './useDOMRef'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusWithin} from 'react-aria/useFocusWithin'; import {useKeyboard} from 'react-aria/useKeyboard'; +import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useVoiceInput, VoiceInputErrorCode} from './useVoiceInput'; @@ -507,7 +508,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { hideArrow placement="bottom start" getTargetRect={target => { - return positionToDOMRange(target, filterAnchor!).getBoundingClientRect(); + return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> {menuItems} @@ -614,7 +615,9 @@ export interface PromptFieldVoiceButtonProps { } export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { - let {lang, isDisabled: isDisabledProp, onError} = props; + let {lang: langProp, isDisabled: isDisabledProp, onError} = props; + let {locale} = useLocale(); + let lang = langProp ?? locale; let {prompt, setPrompt, inputRef, setListening} = useContext(PromptFieldContext); let isDisabled = isDisabledProp; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); @@ -640,7 +643,7 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { // to be inaccurate let finalPrompt = buildVoicePrompt(basePromptRef.current, transcript); inputRef.current.focus(); - setSelection(inputRef.current, finalPrompt.caretPosition, finalPrompt.caretPosition); + setTokenFieldSelection(inputRef.current, finalPrompt.caretPosition, finalPrompt.caretPosition); setPrompt(finalPrompt); }); @@ -788,7 +791,7 @@ function useInsertPromptSegment(buildSegments: (item: any) => TokenFieldSegment[ inputRef.current.focus(); // we need to update the position manually since TokenField's update caret logic only happens if the field is focused // but this insert can happen from the + menu aka the field isn't focused until this gets called which is too late - setSelection(inputRef.current, position, position); + setTokenFieldSelection(inputRef.current, position, position); // the above focus and setCursor call can cause the internally tracked caret position to be reset incorrectly // seemingly due to TokenField's isProgrammaticSelectionChange being flipped to false by setCursor and thus reset to 0 by the .focus // fix this by resetting to proper position below diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index 5479c547983..1453242a4d1 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -48,7 +48,7 @@ import React, { } from 'react'; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; -import {useLayoutEffect} from '@react-aria/utils'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; diff --git a/packages/dev/s2-docs/pages/react-aria/PreviewTrigger.mdx b/packages/dev/s2-docs/pages/react-aria/PreviewTrigger.mdx index cd4dc973a34..541838ffb50 100644 --- a/packages/dev/s2-docs/pages/react-aria/PreviewTrigger.mdx +++ b/packages/dev/s2-docs/pages/react-aria/PreviewTrigger.mdx @@ -17,7 +17,7 @@ export const description = 'Displays a non-modal preview of content on hover, fo ```tsx render type="vanilla" docs={vanillaDocs.exports.Popover} links={vanillaDocs.links} props={['placement', 'offset', 'crossOffset', 'shouldFlip']} "use client"; - import {PreviewTrigger} from 'react-aria-components'; + import {PreviewTrigger} from 'react-aria-components/PreviewTrigger'; import {Popover} from 'vanilla-starter/Popover'; import {Link} from 'vanilla-starter/Link'; import {Button} from 'vanilla-starter/Button'; @@ -68,7 +68,7 @@ export const description = 'Displays a non-modal preview of content on hover, fo ```tsx render type="tailwind" docs={vanillaDocs.exports.Popover} links={vanillaDocs.links} props={['placement', 'offset', 'crossOffset', 'shouldFlip']} "use client"; - import {PreviewTrigger} from 'react-aria-components'; + import {PreviewTrigger} from 'react-aria-components/PreviewTrigger'; import {Popover} from 'tailwind-starter/Popover'; import {Link} from 'tailwind-starter/Link'; import {Button} from 'tailwind-starter/Button'; @@ -127,7 +127,7 @@ When a preview is open, pressing Tab moves focus into the p ```tsx render type="vanilla" docs={docs.exports.PreviewTrigger} links={docs.links} props={['delay', 'closeDelay', 'isDisabled']} "use client"; -import {PreviewTrigger} from 'react-aria-components'; +import {PreviewTrigger} from 'react-aria-components/PreviewTrigger'; import {Popover} from 'vanilla-starter/Popover'; import {Link} from 'vanilla-starter/Link'; import {Button} from 'vanilla-starter/Button'; @@ -181,7 +181,7 @@ function Example(props) { ```tsx render "use client" -import {PreviewTrigger} from 'react-aria-components'; +import {PreviewTrigger} from 'react-aria-components/PreviewTrigger'; import {Focusable} from 'react-aria-components/Tooltip'; import {Popover} from 'vanilla-starter/Popover'; diff --git a/packages/dev/s2-docs/pages/react-aria/Tooltip.mdx b/packages/dev/s2-docs/pages/react-aria/Tooltip.mdx index d02d85148eb..620be921bb5 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tooltip.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tooltip.mdx @@ -103,7 +103,7 @@ function Example(props) { Accessibility - Tooltips are not shown on touch screen interactions. Ensure that your UI is usable without tooltips, or use an alternative component such as a [Popover](Popover) to show information in an adjacent element. + Tooltips are not shown on touch screen interactions. Ensure that your UI is usable without tooltips, or use an alternative component such as a [PreviewTrigger](PreviewTrigger) for interactive content, or a [Popover](Popover) to show information in an adjacent element. ## Custom trigger diff --git a/packages/react-aria-components/exports/PreviewTrigger.ts b/packages/react-aria-components/exports/PreviewTrigger.ts new file mode 100644 index 00000000000..d439afed7a6 --- /dev/null +++ b/packages/react-aria-components/exports/PreviewTrigger.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +// Mark as a client only package. This will cause a build time error if you try +// to import it from a React Server Component in a framework like Next.js. +import 'client-only'; + +export {PreviewTrigger} from '../src/PreviewTrigger'; +export type {PreviewTriggerProps} from '../src/PreviewTrigger'; diff --git a/packages/react-aria-components/exports/TokenField.ts b/packages/react-aria-components/exports/TokenField.ts index 5070da538aa..442ad72dfab 100644 --- a/packages/react-aria-components/exports/TokenField.ts +++ b/packages/react-aria-components/exports/TokenField.ts @@ -20,7 +20,7 @@ export type { TokenRenderProps, TokenFieldContext } from '../src/TokenField'; -export {positionToDOMRange} from 'react-aria/useTokenField'; +export {tokenFieldPositionToDOMRange, setTokenFieldSelection} from 'react-aria/useTokenField'; export {TokenFieldValue, Direction} from 'react-stately/useTokenFieldState'; export type { TokenFieldSegment, diff --git a/packages/react-aria-components/src/PreviewTrigger.tsx b/packages/react-aria-components/src/PreviewTrigger.tsx index 51df02d4f4d..0be34e7b7e3 100644 --- a/packages/react-aria-components/src/PreviewTrigger.tsx +++ b/packages/react-aria-components/src/PreviewTrigger.tsx @@ -23,6 +23,18 @@ import {useTooltipTriggerState} from 'react-stately/useTooltipTriggerState'; export interface PreviewTriggerProps extends AriaPreviewTriggerProps { /** The trigger and Popover that make up the preview trigger. */ children: ReactNode; + /** + * The delay time in milliseconds before the preview opens. + * + * @default 600 + */ + delay?: number; + /** + * The delay time in milliseconds before the preview closes after the pointer leaves. + * + * @default 200 + */ + closeDelay?: number; } /** diff --git a/packages/react-aria-components/stories/TokenField.stories.tsx b/packages/react-aria-components/stories/TokenField.stories.tsx index 89d207dffdb..1c5db91e529 100644 --- a/packages/react-aria-components/stories/TokenField.stories.tsx +++ b/packages/react-aria-components/stories/TokenField.stories.tsx @@ -22,10 +22,10 @@ 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} from 'react-aria/useTokenField'; -import {Token, TokenField, TokenInput} from '../src/TokenField'; import 'vanilla-starter/TagGroup.css'; import {Text} from 'react-aria-components/Text'; +import {Token, TokenField, TokenInput} from '../src/TokenField'; +import {tokenFieldPositionToDOMRange} from 'react-aria/useTokenField'; export default { title: 'React Aria Components/TokenField', @@ -206,7 +206,7 @@ export const WithAutocomplete: TokenFieldStory = () => { placement="bottom start" trigger="MenuTrigger" getTargetRect={target => { - return positionToDOMRange(target, filterAnchor!).getBoundingClientRect(); + return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> {item => ( diff --git a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx index c34831d94a9..bc7b151699c 100644 --- a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx +++ b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx @@ -11,7 +11,7 @@ */ import {expect} from 'vitest'; -import {getSelection, setSelection} from '../../../react-aria/src/tokenfield/useTokenField'; +import {getSelection, setTokenFieldSelection} from '../../../react-aria/src/tokenfield/useTokenField'; import {type Locator, userEvent} from 'vitest/browser'; import {Position, TokenFieldSegment, TokenFieldValue} from 'react-stately/useTokenFieldState'; import React, {useEffect, useState} from 'react'; @@ -99,7 +99,7 @@ export async function focusField(locator: Locator) { } export function setFieldSelection(textboxEl: Element, start: Position, end: Position): void { - setSelection(textboxEl, start, end); + setTokenFieldSelection(textboxEl, start, end); } /** diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts index 98b7c554853..84ae4850e3a 100644 --- a/packages/react-aria/exports/index.ts +++ b/packages/react-aria/exports/index.ts @@ -165,7 +165,12 @@ export {useId} from '../src/utils/useId'; export {useObjectRef} from '../src/utils/useObjectRef'; export {RouterProvider} from '../src/utils/openLink'; export {VisuallyHidden, useVisuallyHidden} from '../src/visually-hidden/VisuallyHidden'; -export {useTokenField} from '../src/tokenfield/useTokenField'; +export { + useTokenField, + tokenFieldPositionToDOMRange, + setTokenFieldSelection, + getSelection +} from '../src/tokenfield/useTokenField'; export {useToken} from '../src/tokenfield/useToken'; export type { diff --git a/packages/react-aria/exports/useTokenField.ts b/packages/react-aria/exports/useTokenField.ts index b57c1c8c7b8..199d3f3ebf7 100644 --- a/packages/react-aria/exports/useTokenField.ts +++ b/packages/react-aria/exports/useTokenField.ts @@ -10,7 +10,7 @@ * governing */ -export {useTokenField, positionToDOMRange, setSelection} from '../src/tokenfield/useTokenField'; +export {useTokenField, tokenFieldPositionToDOMRange, setTokenFieldSelection} 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 index 1f7996e4bba..9f51ed2ec75 100644 --- a/packages/react-aria/src/tokenfield/useToken.ts +++ b/packages/react-aria/src/tokenfield/useToken.ts @@ -16,10 +16,16 @@ import {useEvent} from '../utils/useEvent'; export interface TokenProps {} export interface TokenAria { + /** Props for the token element. */ tokenProps: HTMLAttributes; + /** Whether the token is currently selected. */ isSelected: boolean; } +/** + * Provides the behavior and accessibility implementation for a token within a token field. + * A token field allows users to enter text with inline tokens. + */ export function useToken( // Unused but matches the normal signature. _props: TokenProps, diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 9359385b7a8..99374c93dfd 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -90,6 +90,13 @@ export interface TokenFieldAria { const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json'; +/** + * Provides the behavior and accessibility implementation for a token field. + * A token field allows users to enter text with inline tokens. + * + * @param props - Props for the token field. + * @param state - State for the token field, as returned by `useTokenFieldState`. + */ export function useTokenField( props: AriaTokenFieldProps, state: TokenFieldState, @@ -411,7 +418,7 @@ export function useTokenField( let end = value.findLineBoundary(selection[1], Direction.Forward); if (start && end) { e.preventDefault(); - setSelection(ref.current!, start, end, true); + setTokenFieldSelection(ref.current!, start, end, true); } } }); @@ -654,11 +661,15 @@ function getPosition(container: Element, node: Node, offset: number): Position { let isProgrammaticSelectionChange = Symbol('isProgrammaticSelectionChange'); function setCursor(root: Element, pos: Position, fireEvent = false) { - setSelection(root, pos, pos, fireEvent); + setTokenFieldSelection(root, pos, pos, fireEvent); } -// Exported for tests. -export function setSelection(root: Element, start: Position, end: Position, fireEvent = false) { +export function setTokenFieldSelection( + root: Element, + start: Position, + end: Position, + fireEvent = false +) { let selection = window.getSelection(); if (selection) { let range = createDOMRange(root, start, end); @@ -668,7 +679,7 @@ export function setSelection(root: Element, start: Position, end: Position, fire } } -export function positionToDOMRange(root: Element, pos: Position): Range { +export function tokenFieldPositionToDOMRange(root: Element, pos: Position): Range { return createDOMRange(root, pos, pos); } diff --git a/packages/react-stately/exports/index.ts b/packages/react-stately/exports/index.ts index bda64ed2d45..5b60d451d31 100644 --- a/packages/react-stately/exports/index.ts +++ b/packages/react-stately/exports/index.ts @@ -171,6 +171,13 @@ export type {InvalidationContext} from '../src/virtualizer/types'; export type {RectCorner} from '../src/virtualizer/Rect'; export type {VirtualizerState} from '../src/virtualizer/useVirtualizerState'; export type {TokenFieldProps, TokenFieldState} from '../src/tokenfield/useTokenFieldState'; +export type { + TokenFieldSegment, + TextSegment, + TokenSegment, + Position, + TokenFieldValueOptions +} from '../src/tokenfield/TokenFieldValue'; export {useCalendarState} from '../src/calendar/useCalendarState'; export {useRangeCalendarState} from '../src/calendar/useRangeCalendarState'; @@ -234,3 +241,4 @@ export {ListLayout} from '../src/layout/ListLayout'; export {TableLayout} from '../src/layout/TableLayout'; export {WaterfallLayout} from '../src/layout/WaterfallLayout'; export {useTokenFieldState} from '../src/tokenfield/useTokenFieldState'; +export {TokenFieldValue, Direction} from '../src/tokenfield/TokenFieldValue'; diff --git a/packages/react-stately/src/tokenfield/useTokenFieldState.ts b/packages/react-stately/src/tokenfield/useTokenFieldState.ts index 8cb3d29b9cd..d3b08fccd6b 100644 --- a/packages/react-stately/src/tokenfield/useTokenFieldState.ts +++ b/packages/react-stately/src/tokenfield/useTokenFieldState.ts @@ -30,6 +30,10 @@ export interface TokenFieldState { setComposing: (isComposing: boolean) => void; } +/** + * Provides state management for a token field. Tracks the field value and the + * composition state. + */ export function useTokenFieldState( props: TokenFieldProps ): TokenFieldState { diff --git a/yarn.lock b/yarn.lock index 9e0d3af73db..67c78467c9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7377,7 +7377,6 @@ __metadata: "@internationalized/date": "npm:^3.12.2" "@internationalized/number": "npm:^3.6.7" "@react-aria/test-utils": "npm:^1.0.0-alpha.8" - "@react-aria/utils": "npm:^3.8.0" "@react-spectrum/s2": "npm:1.5.1" "@react-types/shared": "npm:^3.36.0" "@storybook/jest": "npm:^0.2.3" From 7f6a41a67918c23b5ade6af680220de55bcc2ebb Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 29 Jul 2026 14:10:12 -0700 Subject: [PATCH 3/9] rename AutoLinkingTokenFieldValue --- packages/@react-spectrum/ai/exports/index.ts | 2 +- .../@react-spectrum/ai/src/PromptField.tsx | 18 +++++-------- .../ai/stories/Chat.stories.tsx | 26 +++++++++---------- .../ai/stories/PromptField.stories.tsx | 18 ++++++------- .../src/PreviewTrigger.tsx | 2 +- 5 files changed, 31 insertions(+), 35 deletions(-) diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index c261312900b..418a959fcb6 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -3,7 +3,7 @@ export {MessageFeedback} from '../src/MessageFeedback'; export {MessageSource, SourceList, SourceListItem} from '../src/MessageSource'; export {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; export { - AutoLinkingTokenFieldValue, + PromptFieldValue, PromptField, PromptFieldSubmitButton, PromptTokenField, diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 4a9d93a7681..913df348abf 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -147,7 +147,7 @@ function tokenizeURLs(text: string): TokenFieldSegment[] { return segments; } -export class AutoLinkingTokenFieldValue extends TokenFieldValue { +export class PromptFieldValue extends TokenFieldValue { tokenize(text: string): TokenFieldSegment[] { return tokenizeURLs(text); } @@ -156,7 +156,7 @@ export class AutoLinkingTokenFieldValue extends TokenFieldValue { const PromptFieldContext = createContext({ attachments: [], setAttachments: () => {}, - prompt: new AutoLinkingTokenFieldValue([]), + prompt: new PromptFieldValue([]), setPrompt: () => {}, inputRef: createRef(), isGenerating: false, @@ -200,7 +200,7 @@ export const PromptField = forwardRef(function PromptField( let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let [prompt, setPrompt] = useControlledState( props.value, - props.defaultValue ?? new AutoLinkingTokenFieldValue([]), + props.defaultValue ?? new PromptFieldValue([]), props.onChange ); let [attachments, setAttachments] = useControlledState( @@ -248,7 +248,7 @@ export const PromptField = forwardRef(function PromptField( props.onSubmit?.(prompt, attachments); if (!isPromptControlled) { - setPrompt(new AutoLinkingTokenFieldValue([])); + setPrompt(new PromptFieldValue([])); } if (!isAttachmentsControlled) { setAttachments([]); @@ -699,15 +699,11 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { ); } -function buildVoicePrompt(base: TokenFieldValue, voiceText: string): AutoLinkingTokenFieldValue { +function buildVoicePrompt(base: TokenFieldValue, voiceText: string): PromptFieldValue { if (!voiceText) { - return base as AutoLinkingTokenFieldValue; + return base as PromptFieldValue; } - return base.replaceRange( - base.caretPosition, - base.caretPosition, - voiceText - ) as AutoLinkingTokenFieldValue; + return base.replaceRange(base.caretPosition, base.caretPosition, voiceText) as PromptFieldValue; } export interface InsertMenuItemProps { diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 29fa626b867..715184cb6f9 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -14,14 +14,22 @@ import {action} from 'storybook/actions'; import {ActionButton} from '@react-spectrum/s2/ActionButton'; import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; +import {Chat} from '../src/Chat'; +import ChatIcon from '@react-spectrum/s2/icons/Chat'; +import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; +import {Collection} from 'react-aria-components'; +import {Content} from '@react-spectrum/s2/Content'; +import {DialogTrigger, Popover} from '@react-spectrum/s2/Popover'; +import {Image} from '@react-spectrum/s2/Image'; +import {MenuItem} from '@react-spectrum/s2/Menu'; import { - AutoLinkingTokenFieldValue, MessageFeedback, MessageSource, MessageSuggestion, MessageSuggestionList, PromptField, PromptFieldSubmitButton, + PromptFieldValue, PromptTokenField, ResponseStatus, ResponseStatusPanel, @@ -35,14 +43,6 @@ import { TokenFieldValue, UserMessage } from '@react-spectrum/ai'; -import {Chat} from '../src/Chat'; -import ChatIcon from '@react-spectrum/s2/icons/Chat'; -import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; -import {Collection} from 'react-aria-components'; -import {Content} from '@react-spectrum/s2/Content'; -import {DialogTrigger, Popover} from '@react-spectrum/s2/Popover'; -import {Image} from '@react-spectrum/s2/Image'; -import {MenuItem} from '@react-spectrum/s2/Menu'; import type {Meta} from '@storybook/react'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; import {prose} from '../src/style/prose' with {type: 'macro'}; @@ -215,7 +215,7 @@ export function VirtualizedStreamingChat() { let nextId = useRef(initialResponses.length); let [isGenerating, setGenerating] = useState(false); let timeouts = useRef([]); - let [promptValue, setPromptValue] = useState(new AutoLinkingTokenFieldValue([])); + let [promptValue, setPromptValue] = useState(new PromptFieldValue([])); let followUpMessage = useRef(null); function handleSend(prompt: TokenFieldValue) { @@ -561,7 +561,7 @@ export function VirtualizedStreamingChat() { value={promptValue} onChange={setPromptValue} onSubmit={prompt => { - setPromptValue(new AutoLinkingTokenFieldValue([])); + setPromptValue(new PromptFieldValue([])); handleSend(prompt); }} isGenerating={isGenerating} @@ -584,14 +584,14 @@ export function VirtualizedStreamingChat() { e.preventDefault(); if (promptValue.segments.length > 0) { action('onSteer')(promptValue.toString()); - setPromptValue(new AutoLinkingTokenFieldValue([])); + setPromptValue(new PromptFieldValue([])); } } else if (e.key === 'Enter' && e.altKey) { e.preventDefault(); if (promptValue.segments.length > 0) { action('onFollowUp')(promptValue.toString()); followUpMessage.current = promptValue; - setPromptValue(new AutoLinkingTokenFieldValue([])); + setPromptValue(new PromptFieldValue([])); } } else if (e.key === 'Escape') { e.preventDefault(); diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 21356e72fcb..132b78c9d59 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -13,7 +13,6 @@ import {action} from 'storybook/actions'; import { AttachFileMenuItem, - AutoLinkingTokenFieldValue, CommandMenuItem, InsertMenuButton, InsertTextMenuItem, @@ -23,6 +22,7 @@ import { PromptFieldAttachmentList, PromptFieldSubmitButton, PromptFieldToolbar, + PromptFieldValue, PromptFieldVoiceButton, PromptToken, PromptTokenField @@ -245,7 +245,7 @@ interface UploadState { progress?: number; } -let prompt3Base = new AutoLinkingTokenFieldValue([ +let prompt3Base = new PromptFieldValue([ {type: 'text', text: 'Summarize the '}, {type: 'token', text: 'Welcome Flow', value: {type: 'journey', title: 'Welcome Flow'}} ]); @@ -255,12 +255,12 @@ let prompt3End = { }; let prompts = [ - new AutoLinkingTokenFieldValue([ + new PromptFieldValue([ {type: 'text', text: 'Analyze '}, {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, {type: 'text', text: ' and suggest targeting strategies'} ]), - new AutoLinkingTokenFieldValue([ + new PromptFieldValue([ {type: 'text', text: 'Write a brief for '}, { type: 'token', @@ -273,7 +273,7 @@ let prompts = [ function EverythingRender(args) { let {placeholder, ...otherArgs} = args; - let [value, setValue] = useState(() => new AutoLinkingTokenFieldValue([])); + let [value, setValue] = useState(() => new PromptFieldValue([])); let [attachments, setAttachments] = useState([]); let [attachmentState, setAttachmentState] = useState>(new Map()); let historyRef = useRef([]); @@ -330,7 +330,7 @@ function EverythingRender(args) { if (nextIndex >= history.length) { historyIndexRef.current = -1; isHistoryNavigating.current = true; - setValue(new AutoLinkingTokenFieldValue([])); + setValue(new PromptFieldValue([])); } else { historyIndexRef.current = nextIndex; isHistoryNavigating.current = true; @@ -367,7 +367,7 @@ function EverythingRender(args) { action('onSubmit')(prompt.toString()); historyRef.current = [...historyRef.current, prompt]; historyIndexRef.current = -1; - setValue(new AutoLinkingTokenFieldValue([])); + setValue(new PromptFieldValue([])); setAttachments([]); setAttachmentState(new Map()); }} @@ -416,7 +416,7 @@ function EverythingRender(args) { renderCompletions={filterValue => renderCompletions(filterValue, { onClear: () => { - setValue(new AutoLinkingTokenFieldValue([])); + setValue(new PromptFieldValue([])); setAttachments([]); }, onCompact: action('onCompact') @@ -446,7 +446,7 @@ function EverythingRender(args) { { - setValue(new AutoLinkingTokenFieldValue([])); + setValue(new PromptFieldValue([])); setAttachments([]); }}> {item.command} diff --git a/packages/react-aria-components/src/PreviewTrigger.tsx b/packages/react-aria-components/src/PreviewTrigger.tsx index 0be34e7b7e3..2e86981623b 100644 --- a/packages/react-aria-components/src/PreviewTrigger.tsx +++ b/packages/react-aria-components/src/PreviewTrigger.tsx @@ -30,7 +30,7 @@ export interface PreviewTriggerProps extends AriaPreviewTriggerProps { */ delay?: number; /** - * The delay time in milliseconds before the preview closes after the pointer leaves. + * The delay time in milliseconds before the preview closes. * * @default 200 */ From 89a21dd818bc6620a3d9ace645f624273821dabc Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 29 Jul 2026 14:24:37 -0700 Subject: [PATCH 4/9] fix docs and tweak copy a bit --- packages/dev/s2-docs/pages/react-aria/TokenField.mdx | 6 +++--- packages/dev/s2-docs/pages/react-aria/Tooltip.mdx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/TokenField.mdx b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx index aa0869e7c29..4374a8b9846 100644 --- a/packages/dev/s2-docs/pages/react-aria/TokenField.mdx +++ b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx @@ -99,14 +99,14 @@ import {TokenizingFieldValue} from './TokenizingFieldValue'; ## Autocomplete -Combine `TokenField` with [Autocomplete](Autocomplete) to provide inline completions such as @mentions and slash commands. Use `TokenFieldValue.findText` to locate the anchor character, and `positionToDOMRange` to position a [Popover](Popover) relative to the filter text. +Combine `TokenField` with [Autocomplete](Autocomplete) to provide inline completions such as @mentions and slash commands. Use `TokenFieldValue.findText` to locate the anchor character, and `tokenFieldPositionToDOMRange` 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 {tokenFieldPositionToDOMRange} from 'react-aria/useTokenField'; import {Direction, TokenFieldValue} from 'react-aria-components/TokenField'; import {Menu, MenuItem} from 'vanilla-starter/Menu'; import {Popover} from 'vanilla-starter/Popover'; @@ -195,7 +195,7 @@ function Example() { placement="bottom start" trigger="MenuTrigger" getTargetRect={target => { - return positionToDOMRange(target, filterAnchor!).getBoundingClientRect(); + return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> {item => ( diff --git a/packages/dev/s2-docs/pages/react-aria/Tooltip.mdx b/packages/dev/s2-docs/pages/react-aria/Tooltip.mdx index 620be921bb5..fd98b2500ba 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tooltip.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tooltip.mdx @@ -103,7 +103,7 @@ function Example(props) { Accessibility - Tooltips are not shown on touch screen interactions. Ensure that your UI is usable without tooltips, or use an alternative component such as a [PreviewTrigger](PreviewTrigger) for interactive content, or a [Popover](Popover) to show information in an adjacent element. + Tooltips are not shown on touch screen interactions. Ensure that your UI is usable without tooltips, or use an alternative component such as a [PreviewTrigger](PreviewTrigger) or a [Popover](Popover) to show information in an adjacent element. ## Custom trigger From 0f5b698aab4bb9688b1699bbee07be087a92c042 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 29 Jul 2026 14:31:13 -0700 Subject: [PATCH 5/9] ugh lint --- .../test/utils/tokenFieldBrowserUtils.tsx | 5 ++++- packages/react-aria/exports/useTokenField.ts | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx index bc7b151699c..e9a56002477 100644 --- a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx +++ b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx @@ -11,7 +11,10 @@ */ import {expect} from 'vitest'; -import {getSelection, setTokenFieldSelection} from '../../../react-aria/src/tokenfield/useTokenField'; +import { + getSelection, + setTokenFieldSelection +} from '../../../react-aria/src/tokenfield/useTokenField'; import {type Locator, userEvent} from 'vitest/browser'; import {Position, TokenFieldSegment, TokenFieldValue} from 'react-stately/useTokenFieldState'; import React, {useEffect, useState} from 'react'; diff --git a/packages/react-aria/exports/useTokenField.ts b/packages/react-aria/exports/useTokenField.ts index 199d3f3ebf7..61f3de508a0 100644 --- a/packages/react-aria/exports/useTokenField.ts +++ b/packages/react-aria/exports/useTokenField.ts @@ -10,7 +10,11 @@ * governing */ -export {useTokenField, tokenFieldPositionToDOMRange, setTokenFieldSelection} from '../src/tokenfield/useTokenField'; +export { + useTokenField, + tokenFieldPositionToDOMRange, + setTokenFieldSelection +} 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'; From d95fe07d1c416bb75b04a58a7f340f81854c454d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 29 Jul 2026 15:55:16 -0700 Subject: [PATCH 6/9] expose promptfield input ref so user can have autofill buttons open autocomplete menu --- .../@react-spectrum/ai/src/PromptField.tsx | 15 +++++------ packages/@react-spectrum/ai/src/useDOMRef.ts | 26 ++++++++++++++++++- .../ai/stories/PromptField.stories.tsx | 12 +++++++-- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 913df348abf..bea24f917f8 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -44,7 +44,7 @@ import { TokenFieldValue, TokenSegment } from 'react-stately/useTokenFieldState'; -import {DOMRef} from '@react-types/shared'; +import {FocusableRef} from '@react-types/shared'; import {IconContext} from '@react-spectrum/s2'; import {Image, Text} from '@react-spectrum/s2/Card'; // @ts-ignore @@ -71,7 +71,7 @@ import { } from 'react-aria-components/TokenField'; import {Tooltip, TooltipTrigger} from '@react-spectrum/s2/Tooltip'; import {useControlledState} from 'react-stately/useControlledState'; -import {useDOMRef} from './useDOMRef'; +import {useFocusableRef} from './useDOMRef'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusWithin} from 'react-aria/useFocusWithin'; import {useKeyboard} from 'react-aria/useKeyboard'; @@ -183,7 +183,7 @@ function matchMimeType(mimeType: string, acceptedMimeTypes: string[]): boolean { export const PromptField = forwardRef(function PromptField( props: PromptFieldProps, - ref: DOMRef + ref: FocusableRef ) { let { children, @@ -196,7 +196,10 @@ export const PromptField = forwardRef(function PromptField( variant = 'balanced', brandColor } = props; - let domRef = useDOMRef(ref); + // Not using RAC DropZone because it adds its own focusable button, + // and we want to avoid an extra tab. We support pasting files directly into the input. + let inputRef = useRef(null); + let domRef = useFocusableRef(ref, inputRef); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let [prompt, setPrompt] = useControlledState( props.value, @@ -208,10 +211,6 @@ export const PromptField = forwardRef(function PromptField( props.defaultAttachments ?? [], props.onAttachmentsChange ); - - // Not using RAC DropZone because it adds its own focusable button, - // and we want to avoid an extra tab. We support pasting files directly into the input. - let inputRef = useRef(null); let {dropProps, isDropTarget} = useDrop({ ref: inputRef, hasDropButton: true, diff --git a/packages/@react-spectrum/ai/src/useDOMRef.ts b/packages/@react-spectrum/ai/src/useDOMRef.ts index 8b3afaf7881..7cc24518b7c 100644 --- a/packages/@react-spectrum/ai/src/useDOMRef.ts +++ b/packages/@react-spectrum/ai/src/useDOMRef.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {DOMRef, DOMRefValue, RefObject} from '@react-types/shared'; +import {DOMRef, DOMRefValue, FocusableRef, FocusableRefValue, RefObject} from '@react-types/shared'; import {useImperativeHandle, useRef} from 'react'; export function createDOMRef( @@ -23,6 +23,21 @@ export function createDOMRef( }; } +export function createFocusableRef( + domRef: RefObject, + focusableRef?: RefObject +): FocusableRefValue { + let resolvedFocusableRef = focusableRef || domRef; + return { + ...createDOMRef(domRef), + focus() { + if (resolvedFocusableRef.current) { + resolvedFocusableRef.current.focus(); + } + } + }; +} + export function useDOMRef( ref: DOMRef ): RefObject { @@ -30,3 +45,12 @@ export function useDOMRef( useImperativeHandle(ref, () => createDOMRef(domRef)); return domRef; } + +export function useFocusableRef( + ref: FocusableRef, + focusableRef?: RefObject +): RefObject { + let domRef = useRef(null); + useImperativeHandle(ref, () => createFocusableRef(domRef, focusableRef)); + return domRef; +} diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 132b78c9d59..49608fa5abb 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -51,6 +51,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import Plugin from '@react-spectrum/s2/icons/Plugin'; import Prompt from '@react-spectrum/s2/icons/Prompt'; import SocialNetwork from '@react-spectrum/s2/icons/SocialNetwork'; +import type {FocusableRefValue} from '@react-types/shared'; import {TokenFieldValue} from 'react-aria-components'; import {useRef, useState} from 'react'; import UserGroup from '@react-spectrum/s2/icons/UserGroup'; @@ -268,12 +269,13 @@ let prompts = [ value: {type: 'campaign', title: 'Spring Launch 2026'} } ]), - prompt3Base.replaceRange(prompt3End, prompt3End, ' journey performance from test.com ') + prompt3Base.replaceRange(prompt3End, prompt3End, ' journey performance from test.com /') ]; function EverythingRender(args) { let {placeholder, ...otherArgs} = args; let [value, setValue] = useState(() => new PromptFieldValue([])); + let promptFieldRef = useRef>(null); let [attachments, setAttachments] = useState([]); let [attachmentState, setAttachmentState] = useState>(new Map()); let historyRef = useRef([]); @@ -352,13 +354,19 @@ function EverythingRender(args) {
{prompts.map((prompt, i) => ( - setValue(prompt)}> + { + setValue(prompt); + promptFieldRef.current?.focus(); + }}> {prompt.toString()} ))} Date: Fri, 31 Jul 2026 15:14:53 -0700 Subject: [PATCH 7/9] truncate attachments and onToggle for voice input omega tracking --- .../@react-spectrum/ai/src/HorizontalCard.tsx | 21 ++++++---- .../@react-spectrum/ai/src/PromptField.tsx | 9 ++++- .../ai/stories/AttachmentList.stories.tsx | 23 +++++++++++ .../ai/stories/PromptField.stories.tsx | 39 ++++++++++--------- yarn.lock | 5 --- 5 files changed, 65 insertions(+), 32 deletions(-) diff --git a/packages/@react-spectrum/ai/src/HorizontalCard.tsx b/packages/@react-spectrum/ai/src/HorizontalCard.tsx index bdbd2e80f9e..9b48abe1963 100644 --- a/packages/@react-spectrum/ai/src/HorizontalCard.tsx +++ b/packages/@react-spectrum/ai/src/HorizontalCard.tsx @@ -312,7 +312,7 @@ const image = style({ pointerEvents: 'none' }); -let title = style({ +let title = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'; isBasic?: boolean}>({ font: 'title', fontSize: { size: { @@ -323,11 +323,14 @@ let title = style({ XL: 'title-lg' } }, - lineClamp: 3, + lineClamp: { + default: 3, + isBasic: 1 + }, gridArea: 'title' }); -let description = style({ +let description = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'; isBasic?: boolean}>({ font: 'body', fontSize: { size: { @@ -338,7 +341,10 @@ let description = style({ XL: 'body' } }, - lineClamp: 3, + lineClamp: { + default: 3, + isBasic: 1 + }, gridArea: 'description' }); @@ -347,7 +353,7 @@ let content = style({ // By default, all elements are displayed in a stack. // If an action menu is present, place it next to the title. gridTemplateColumns: { - default: ['1fr'], + default: ['minmax(0, 1fr)'], ':has([data-slot=menu])': ['minmax(0, 1fr)', 'auto'] }, gridTemplateAreas: { @@ -356,6 +362,7 @@ let content = style({ }, columnGap: 4, flexGrow: 1, + minWidth: 0, alignItems: 'baseline', alignContent: 'start', rowGap: { @@ -456,8 +463,8 @@ const Card = forwardRef(function Card( { slots: { [DEFAULT_SLOT]: {}, - title: {styles: title({size})}, - description: {styles: description({size})} + title: {styles: title({size, isBasic})}, + description: {styles: description({size, isBasic})} } } ], diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 687c2d1756d..4f3762636f9 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -610,10 +610,11 @@ export interface PromptFieldVoiceButtonProps { lang?: string; isDisabled?: boolean; onError?: (code: VoiceInputErrorCode) => void; + onToggle?: (isListening: boolean) => void; } export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { - let {lang: langProp, isDisabled: isDisabledProp, onError} = props; + let {lang: langProp, isDisabled: isDisabledProp, onError, onToggle} = props; let {locale} = useLocale(); let lang = langProp ?? locale; let {prompt, setPrompt, inputRef, setListening} = useContext(PromptFieldContext); @@ -645,14 +646,20 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { setPrompt(finalPrompt); }); + let onToggleEvent = useEffectEvent((isListening: boolean) => { + onToggle?.(isListening); + }); + let wasListeningRef = useRef(false); useEffect(() => { if (isVoiceListening) { updateBasePrompt(); wasListeningRef.current = true; + onToggleEvent(true); } else if (wasListeningRef.current) { wasListeningRef.current = false; restoreFocus(); + onToggleEvent(false); } }, [isVoiceListening]); diff --git a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx index cae286fa369..0f11cf553b8 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx @@ -99,3 +99,26 @@ function AttachmentListRender(args) { export const AIAttachmentList: Story = { render: args => }; + +export const LongContents: Story = { + name: 'Long contents', + render: (args: any) => ( + + + + + Very long file name that exceeds the container width.pdf + + Long long long long long long long long long long description. + + + + + ) +}; diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 49608fa5abb..b6c76d4450b 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -246,30 +246,31 @@ interface UploadState { progress?: number; } +function atEnd(v: PromptFieldValue) { + let segs = v.segments; + return {index: segs.length - 1, offset: segs[segs.length - 1].text.length}; +} + +let prompt1 = new PromptFieldValue([ + {type: 'text', text: 'Analyze '}, + {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, + {type: 'text', text: ' and suggest targeting strategies'} +]); + +let prompt2 = new PromptFieldValue([ + {type: 'text', text: 'Write a brief for '}, + {type: 'token', text: 'Spring Launch 2026', value: {type: 'campaign', title: 'Spring Launch 2026'}} +]); + let prompt3Base = new PromptFieldValue([ {type: 'text', text: 'Summarize the '}, {type: 'token', text: 'Welcome Flow', value: {type: 'journey', title: 'Welcome Flow'}} ]); -let prompt3End = { - index: 1, - offset: prompt3Base.segments[1].text.length -}; let prompts = [ - new PromptFieldValue([ - {type: 'text', text: 'Analyze '}, - {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, - {type: 'text', text: ' and suggest targeting strategies'} - ]), - new PromptFieldValue([ - {type: 'text', text: 'Write a brief for '}, - { - type: 'token', - text: 'Spring Launch 2026', - value: {type: 'campaign', title: 'Spring Launch 2026'} - } - ]), - prompt3Base.replaceRange(prompt3End, prompt3End, ' journey performance from test.com /') + prompt1.withCaretPosition(atEnd(prompt1)), + prompt2.withCaretPosition(atEnd(prompt2)), + prompt3Base.replaceRange(atEnd(prompt3Base), atEnd(prompt3Base), ' journey performance from test.com /') ]; function EverythingRender(args) { @@ -516,7 +517,7 @@ function EverythingRender(args) { {/* TODO is this kind of styling expected from the user? Or should we have a slot that places the mic button next to the submit button? */}
- +
diff --git a/yarn.lock b/yarn.lock index 99a75af5a28..6330a4fde4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7377,13 +7377,8 @@ __metadata: "@internationalized/date": "npm:^3.12.3" "@internationalized/number": "npm:^3.6.7" "@react-aria/test-utils": "npm:^1.0.0-alpha.8" -<<<<<<< HEAD - "@react-spectrum/s2": "npm:1.5.1" - "@react-types/shared": "npm:^3.36.0" -======= "@react-spectrum/s2": "npm:1.6.0" "@react-types/shared": "npm:^3.36.1" ->>>>>>> 0b5220fa728ce71e36515783bc96334a0da30505 "@storybook/jest": "npm:^0.2.3" "@testing-library/dom": "npm:^10.1.0" "@testing-library/react": "npm:^16.0.0" From 22ffe317ab927ce42b43fa37bfe3201017a35a3e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 31 Jul 2026 16:13:55 -0700 Subject: [PATCH 8/9] handle icon thumbnails in attachment list --- .../@react-spectrum/ai/src/AttachmentList.tsx | 81 +++++++++++++------ .../@react-spectrum/ai/src/HorizontalCard.tsx | 21 +++++ .../@react-spectrum/ai/src/PromptField.tsx | 6 +- .../ai/stories/AttachmentList.stories.tsx | 43 ++++++++++ .../ai/stories/PromptField.stories.tsx | 14 +++- 5 files changed, 135 insertions(+), 30 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index c67d6c9945e..da3e9cb4f49 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -28,13 +28,15 @@ import {BasicHorizontalCard} from './HorizontalCard'; import {Button} from 'react-aria-components/Button'; import {CardProps} from '@react-spectrum/s2/Card'; import Cross from '../ui-icons/Cross'; -import {forwardRef, ReactNode, useRef} from 'react'; +import {forwardRef, ReactNode, useContext, useRef} from 'react'; +import {IconContext} from '@react-spectrum/s2/Icon'; import {ImageContext} from '@react-spectrum/s2/Image'; // @ts-ignore import intlMessages from '../intl/*.json'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; +import {Provider} from 'react-aria-components/slots'; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { Tag, @@ -217,6 +219,56 @@ const attachmentErrorStyles = style({ } }); +function AttachmentContextProvider({ + children, + isUploading +}: { + children: ReactNode; + isUploading: boolean; +}) { + let imageCtx = useContext(ImageContext); + let iconCtx = useContext(IconContext); + const opacityStyles = style({ + opacity: {default: 1, isUploading: 0.15}, + transition: 'default' + })({isUploading}); + const imageSlots = imageCtx && 'slots' in imageCtx ? imageCtx.slots : undefined; + const iconSlots = iconCtx && 'slots' in iconCtx ? iconCtx.slots : undefined; + + return ( + + {children} + + ); +} + export const Attachment = forwardRef(function Attachment( props: AttachmentProps, ref: DOMRef @@ -266,29 +318,10 @@ export const Attachment = forwardRef(function Attachment( />
)} - {/* Reduce opacity of the thumbnail if upload is in progress */} - - {ctx => ( - - {typeof children === 'function' ? children({size}) : children} - - )} - + + {typeof children === 'function' ? children({size}) : children} + {isInvalid && ( @@ -482,10 +487,12 @@ export interface PromptTokenFieldPopoverProps extends Omit; isFocused?: boolean; + // TODO: temp for coworker see above comment + menuWidth?: number; } function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { - let {filterAnchor, items, isFocused} = props; + let {filterAnchor, items, isFocused, menuWidth} = props; let {inputRef} = useContext(PromptFieldContext); let resolvedItems = items instanceof Promise ? use(items) : items; @@ -505,6 +512,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { isNonModal hideArrow placement="bottom start" + UNSAFE_style={menuWidth != null ? {width: menuWidth} : undefined} getTargetRect={target => { return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 7dada8a7c74..e8c004ba22d 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -90,6 +90,10 @@ const meta: Meta = { placeholder: { control: 'text', table: {category: 'PromptTokenField'} + }, + menuWidth: { + control: 'number', + table: {category: 'PromptTokenField'} } }, args: { @@ -98,6 +102,7 @@ const meta: Meta = { attachmentVariant: 'thumbnail', attachmentInvalid: false, placeholder: undefined, + menuWidth: undefined, ...getActionArgs(events) }, title: 'AI/PromptField', @@ -282,7 +287,7 @@ let prompts = [ ]; function EverythingRender(args) { - let {placeholder, ...otherArgs} = args; + let {placeholder, menuWidth, ...otherArgs} = args; let [value, setValue] = useState(() => new PromptFieldValue([])); let promptFieldRef = useRef>(null); let [attachments, setAttachments] = useState([]); @@ -441,6 +446,7 @@ function EverythingRender(args) { } pixelLoader={data[args.pixelLoader]} placeholder={placeholder} + menuWidth={menuWidth} onKeyDown={onKeyDown}> {segment => (