From f841a11502c855fef3bf1857d0a6fb79f7c1b8e5 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 20 Aug 2026 19:50:39 -0400 Subject: [PATCH 1/3] update attachment design --- packages/@react-spectrum/ai/exports/index.ts | 8 +- .../@react-spectrum/ai/src/AttachmentList.tsx | 320 ++++++++---------- .../@react-spectrum/ai/src/PromptField.tsx | 2 +- .../ai/stories/AttachmentList.stories.tsx | 49 +-- .../ai/stories/PromptField.stories.tsx | 46 +-- 5 files changed, 182 insertions(+), 243 deletions(-) diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 7fd5afd63de..fc47ff18a0c 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -1,4 +1,4 @@ -export {Attachment, AttachmentList} from '../src/AttachmentList'; +export {Attachment, AttachmentList, AttachmentPreview} from '../src/AttachmentList'; export {MessageFeedback} from '../src/MessageFeedback'; export {MessageSource, SourceList, SourceListItem} from '../src/MessageSource'; export {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; @@ -37,7 +37,11 @@ export {UserMessage} from '../src/UserMessage'; export {PixelLoader} from '../src/loader/react'; export {AIButton} from '../src/AIButton'; -export type {AttachmentProps, AttachmentListProps} from '../src/AttachmentList'; +export type { + AttachmentProps, + AttachmentListProps, + AttachmentPreviewProps +} from '../src/AttachmentList'; export type { PromptFieldProps, PromptFieldSubmitButtonProps, diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index a2b79077ad9..3da985bf1be 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -18,27 +18,31 @@ import { forwardRefType, GlobalDOMAttributes } from '@react-types/shared'; +import AudioWave from '@react-spectrum/s2/icons/AudioWave'; import { baseColor, color, focusRing, iconStyle, lightDark, - space, style } from '@react-spectrum/s2/style' with {type: 'macro'}; import {Button} from 'react-aria-components/Button'; import {CardProps} from '@react-spectrum/s2/Card'; import {ContentContext} from '@react-spectrum/s2/Content'; +import {createContext, forwardRef, ReactNode, useContext, useRef} from 'react'; import Cross from '../ui-icons/Cross'; import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; -import {forwardRef, ReactNode, useContext, useRef} from 'react'; +import File from '@react-spectrum/s2/icons/File'; +import FileText from '@react-spectrum/s2/icons/FileText'; +// @ts-ignore import {IllustrationContext} from '@react-spectrum/s2/Icon'; -import {ImageContext} from '@react-spectrum/s2/Image'; +import {Image, ImageContext, ImageProps} from '@react-spectrum/s2/Image'; import {ImageCoordinator} from '@react-spectrum/s2/ImageCoordinator'; -// @ts-ignore +import ImageIcon from '@react-spectrum/s2/icons/Image'; import intlMessages from '../intl/*.json'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; +import Play from '@react-spectrum/s2/icons/Play'; import {pressScale} from '@react-spectrum/s2/pressScale'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -69,12 +73,7 @@ const controlSizeM = { } } as const; -const hoverBackground = { - default: 'gray-200', - isStaticColor: 'transparent-overlay-200' -} as const; - -const styles = style<{ +const closeButton = style<{ isDisabled: boolean; isHovered: boolean; isFocusVisible: boolean; @@ -92,16 +91,14 @@ const styles = style<{ borderStyle: 'none', transition: 'default', backgroundColor: { - default: 'gray-200', - isHovered: hoverBackground, - isFocusVisible: hoverBackground, - isPressed: hoverBackground + default: baseColor('gray-900'), + forcedColors: 'ButtonText' }, color: { - default: baseColor('neutral'), + default: baseColor('gray-25'), isDisabled: 'disabled', forcedColors: { - default: 'ButtonText', + default: 'ButtonFace', isDisabled: 'GrayText' } }, @@ -117,22 +114,40 @@ const styles = style<{ }); const onlyPreview = ':not(:has([data-slot=content])):not(:has([data-slot=preview]))'; -const noDescription = ':not(:has([slot=description]))'; + +const container = { + backgroundColor: { + default: lightDark('white/5', 'black/5'), + forcedColors: 'ButtonFace' + }, + boxShadow: `[inset 0 0 0 1px light-dark(${color('black/5')}, ${color('white/5')}), 0 8px 32px 0 light-dark(${color('transparent-white-50')}, ${color('transparent-black-50')}), inset 0 -5px 21.6px 0 ${color('transparent-white-50')}, inset 0 24px 32px 0 ${color('transparent-white-50')}]` +} as const; const attachmentCard = style({ + ...container, display: 'flex', flexDirection: 'row', position: 'relative', - borderRadius: 'default', - backgroundColor: { - default: lightDark('transparent-white-300', 'transparent-black-300'), - forcedColors: 'ButtonFace' + borderRadius: 'lg', + outlineStyle: 'solid', + outlineWidth: { + default: 1, // WHCM + isInvalid: 2 }, - boxShadow: { - default: `[inset 0 0 0 1px light-dark(${color('transparent-black-300')}, ${color('transparent-white-300')})]`, - isInvalid: `[inset 0 0 0 1px ${color('negative-900')}]` + outlineOffset: { + default: -1, + isInvalid: -2 + }, + outlineColor: { + default: 'transparent', + forcedColors: 'ButtonBorder', + isInvalid: { + default: 'negative-900', + forcedColors: 'Mark' + } }, forcedColorAdjust: 'none', + cursor: 'default', transition: 'default', fontFamily: 'sans', overflow: 'clip', @@ -264,7 +279,7 @@ const attachmentDescription = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'}>({ gridArea: 'description' }); -const attachmentContent = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'}>({ +const attachmentContent = style({ display: 'grid', gridTemplateColumns: ['minmax(0, 1fr)'], gridTemplateAreas: ['title', 'description'], @@ -273,16 +288,6 @@ const attachmentContent = style<{size: 'XS' | 'S' | 'M' | 'L' | 'XL'}>({ minWidth: 0, alignItems: 'baseline', alignContent: 'start', - rowGap: { - size: { - XS: 4, - S: 4, - M: space(6), - L: space(6), - XL: 8 - }, - [noDescription]: 0 - }, paddingStart: { default: '--card-spacing', ':first-child': 0 @@ -303,7 +308,7 @@ const CloseButton = function CloseButton(props) { slot="remove" style={pressScale(ref, {})} className={renderProps => - mergeStyles(styles({...renderProps, size: props.size || 'M'}), props.styles) + mergeStyles(closeButton({...renderProps, size: props.size || 'M'}), props.styles) }> @@ -387,101 +392,8 @@ const tagStyles = style({ flexGrow: 0, position: 'relative', ...focusRing(), - borderRadius: 'default' + borderRadius: 'lg' }); - -// this is checking that there isn't content in the attachment -// similar to onlyPreview, but specifically checking siblings before the alert icon (aka looking for Content) -const onlyPreviewFromError = ':not([data-slot=content] ~ *)'; -const attachmentErrorStyles = style({ - display: 'flex', - flexShrink: 0, - alignItems: 'center', - paddingStart: { - default: 8, - [onlyPreviewFromError]: 0 - }, - position: { - [onlyPreviewFromError]: 'absolute' - }, - top: { - [onlyPreviewFromError]: '50%' - }, - insetStart: { - [onlyPreviewFromError]: '50%' - }, - transform: { - [onlyPreviewFromError]: 'translate(-50%, -50%)' - }, - '--iconPrimary': { - type: 'color', - value: 'negative' - } -}); - -// this is also checking that there isn't content in the attachment -// similar to onlyPreview, but specifically checking siblings after the thumbnail (aka looking for Content) -const onlyPreviewFromThumbnail = ':not(:has(~ [data-slot=content]))'; -function AttachmentContextProvider({ - children, - isUploading, - isInvalid -}: { - children: ReactNode; - isUploading: boolean; - isInvalid?: boolean; -}) { - let imageCtx = useContext(ImageContext); - let illustrationCtx = useContext(IllustrationContext); - const opacityStyles = style({ - opacity: { - default: 1, - isUploading: 0.15, - isInvalid: { - default: 1, - [onlyPreviewFromThumbnail]: 0.15 - } - }, - transition: 'default' - })({isUploading, isInvalid}); - const imageSlots = imageCtx && 'slots' in imageCtx ? imageCtx.slots : undefined; - const illustrationSlots = - illustrationCtx && 'slots' in illustrationCtx ? illustrationCtx.slots : undefined; - - return ( - - {children} - - ); -} - interface AttachmentCardProps { size?: 'XS' | 'S' | 'M' | 'L' | 'XL'; isInvalid?: boolean; @@ -509,14 +421,10 @@ function AttachmentCard({size = 'M', isInvalid = false, children}: AttachmentCar borderRadius: '[3px]', objectFit: 'cover', outlineStyle: 'solid', - outlineWidth: { - default: 2, - size: { - XS: 1 - } - }, - outlineColor: '--s2-container-bg' - })({size}) + outlineWidth: 1, + outlineColor: 'gray-800/10', + outlineOffset: -1 + }) } } } @@ -546,7 +454,7 @@ function AttachmentCard({size = 'M', isInvalid = false, children}: AttachmentCar [ ContentContext, { - styles: attachmentContent({size}), + styles: attachmentContent, // @ts-ignore 'data-slot': 'content' } @@ -574,7 +482,6 @@ export const Attachment = forwardRef(function Attachment( size = 'M' } = props; let domRef = useDOMRef(ref); - let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); return ( mergeStyles(tagStyles({...renderProps}), styles)}> - {props.uploadProgress != null && props.uploadProgress < 100 && ( -
- -
- )} - + {typeof children === 'function' ? children({size}) : children} - - {isInvalid && ( - - )} +
{/** Definitely not a close button, though looks like one. */}
+ +
+ ); + } + + if (uploadProgress < 100) { + return ( +
+ +
+ ); + } + + if (otherProps.src) { + return ; + } + + if (mimeType.startsWith('audio/')) { + return ( +
+ +
+ ); + } + + if (mimeType.startsWith('video/')) { + return ( +
+ +
+ ); + } + + if (mimeType.startsWith('image/')) { + return ( +
+ +
+ ); + } + + if (mimeType.startsWith('text/')) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} + function AlertTriangleIcon({size}) { switch (size) { case 'XS': - return ; + return ; case 'S': - return ; + return ; case 'M': - return ; + return ; case 'L': - return ; + return ; case 'XL': - return ; + return ; } } diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 3aed8e1fa58..acd906ec6d5 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -543,7 +543,7 @@ export function PromptTokenField(props: PromptTokenFieldProps) { '--loader-opacity': { type: 'opacity', value: { - default: 0.51, + default: 0.64, isFocused: 1, forcedColors: 1 } diff --git a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx index e4c9f865ec5..a3101ea7bf6 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx @@ -10,13 +10,13 @@ * governing permissions and limitations under the License. */ -import {Attachment as AttachmentComponent, AttachmentList} from '../src/AttachmentList'; +import { + Attachment as AttachmentComponent, + AttachmentList, + AttachmentPreview +} from '../src/AttachmentList'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; import {Content} from '@react-spectrum/s2/Content'; -import FileTextIllustration from '../../s2/spectrum-illustrations/gradient/generic1/FileText'; -import FileVideo from '@react-spectrum/s2/illustrations/gradient/generic1/FileVideo'; -import FileZip from '@react-spectrum/s2/illustrations/gradient/generic1/FileZip'; -import {Image} from '@react-spectrum/s2/Image'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Text} from '@react-spectrum/s2/Text'; @@ -56,7 +56,8 @@ function AttachmentListRender(args) { isInvalid={isInvalid} size={size} aria-label="Demo file.pdf"> - @@ -66,7 +67,8 @@ function AttachmentListRender(args) { isInvalid={isInvalid} size={size} aria-label="Alligator.pdf"> - @@ -76,7 +78,8 @@ function AttachmentListRender(args) { isInvalid={isInvalid} size={size} aria-label="Rules.pdf"> - @@ -86,7 +89,8 @@ function AttachmentListRender(args) { isInvalid={isInvalid} size={size} aria-label="Echidna.pdf"> - @@ -112,14 +116,14 @@ function NonImageAttachmentListRender(args) { isInvalid={isInvalid} size={size} aria-label="report.pdf"> - + - + notes.txt Plain text document @@ -130,7 +134,7 @@ function NonImageAttachmentListRender(args) { isInvalid={isInvalid} size={size} aria-label="data.csv"> - + data.csv @@ -151,7 +155,8 @@ export const LongContents: Story = { size={args.size} styles={style({maxWidth: 300})} aria-label="Very long file name that exceeds the container width.pdf"> - @@ -177,8 +182,9 @@ function MixedAttachments(args) { isInvalid={isInvalid} size={size} aria-label="banner.png"> - @@ -187,21 +193,21 @@ function MixedAttachments(args) { isInvalid={isInvalid} size={size} aria-label="notes.tsx"> - + - + - + @@ -210,8 +216,9 @@ function MixedAttachments(args) { isInvalid={isInvalid} size={size} aria-label="banner.png"> - @@ -224,7 +231,7 @@ function MixedAttachments(args) { isInvalid={isInvalid} size={size} aria-label="notes.txt"> - + notes.txt Plain text @@ -235,7 +242,7 @@ function MixedAttachments(args) { isInvalid={isInvalid} size={size} aria-label="video.mp4"> - + video.mp4 MP4 @@ -246,7 +253,7 @@ function MixedAttachments(args) { isInvalid={isInvalid} size={size} aria-label="debug.zip"> - + debug.zip ZIP diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index e25515ab25a..65cdf07f76f 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -29,7 +29,7 @@ import { PromptToken, PromptTokenField } from '../src/PromptField'; -import {Attachment} from '../src/AttachmentList'; +import {Attachment, AttachmentPreview} from '../src/AttachmentList'; import Brand from '@react-spectrum/s2/icons/Brand'; import {Button} from '@react-spectrum/s2/Button'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; @@ -50,9 +50,6 @@ import Data from '@react-spectrum/s2/icons/Data'; import * as data from '../src/loader/data'; import type {FocusableRefValue} from '@react-types/shared'; import {iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Image} from '@react-spectrum/s2/Image'; -import Keyboard from '@react-spectrum/s2/icons/Keyboard'; -import {LinkButton} from '@react-spectrum/s2/LinkButton'; import LinkIcon from '@react-spectrum/s2/icons/Link'; import ListMultiSelect from '@react-spectrum/s2/icons/ListMultiSelect'; import {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; @@ -292,37 +289,6 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) return null; } -function ToolbarButtons() { - return ( -
- - - Plan mode - - - - - Normal - - - -
- Transcript view -
- Normal -
-
-
- - - Terms and conditions - -
- ); -} - interface UploadState { status: 'uploading' | 'completed'; progress?: number; @@ -483,7 +449,7 @@ function EverythingRender(args) { setAttachments([]); setAttachmentState(new Map()); }} - acceptedAttachmentTypes={['image/*']} + acceptedAttachmentTypes={['*/*']} onAddAttachments={newAttachments => { setAttachmentState(prev => { let newState = new Map(prev); @@ -511,12 +477,13 @@ function EverythingRender(args) { - {/* TODO: what about non-image attachments? */} - {attachment.image && } + {args.attachmentVariant === 'card' && ( {attachment.file.name} - {attachment.file.type} + + {attachment.file.type.split('/').pop()?.toUpperCase()} + )} @@ -640,7 +607,6 @@ 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? */}
From c73b6e155f2ad1c5ffd62c9a38cc7c3f9dee97b8 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 20 Aug 2026 19:57:40 -0400 Subject: [PATCH 2/3] lint --- packages/@react-spectrum/ai/stories/PromptField.stories.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 65cdf07f76f..edf4cc93d1c 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -11,7 +11,6 @@ */ import {action} from 'storybook/actions'; -import {ActionButton} from '@react-spectrum/s2/ActionButton'; import { AttachFileMenuItem, CommandMenuItem, @@ -31,7 +30,6 @@ import { } from '../src/PromptField'; import {Attachment, AttachmentPreview} from '../src/AttachmentList'; import Brand from '@react-spectrum/s2/icons/Brand'; -import {Button} from '@react-spectrum/s2/Button'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import { @@ -41,7 +39,6 @@ import { Menu, MenuItem, MenuSection, - MenuTrigger, SubmenuTrigger, Text } from '@react-spectrum/s2/Menu'; @@ -51,13 +48,11 @@ import * as data from '../src/loader/data'; import type {FocusableRefValue} from '@react-types/shared'; import {iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import LinkIcon from '@react-spectrum/s2/icons/Link'; -import ListMultiSelect from '@react-spectrum/s2/icons/ListMultiSelect'; import {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; 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 {ToggleButton} from '@react-spectrum/s2/ToggleButton'; import {TokenFieldValue} from 'react-aria-components'; import {TokenSegment} from 'react-stately'; import {useRef, useState} from 'react'; From d5bf4e1b6d75f8f8b0b42c12ae15715fb1e9d3b1 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 20 Aug 2026 20:02:37 -0400 Subject: [PATCH 3/3] fix test --- packages/@react-spectrum/ai/test/PromptField.test.tsx | 2 +- .../@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/ai/test/PromptField.test.tsx b/packages/@react-spectrum/ai/test/PromptField.test.tsx index 6729af91ab9..4cbbb858ad2 100644 --- a/packages/@react-spectrum/ai/test/PromptField.test.tsx +++ b/packages/@react-spectrum/ai/test/PromptField.test.tsx @@ -440,7 +440,7 @@ describeOrSkip('PromptField', () => { let {container} = renderPromptField({attachments: [imageAttachment('a1')], invalid: true}); expect(screen.getByLabelText('Attachments')).toBeInTheDocument(); // The invalid state renders a decorative alert icon. - expect(container.querySelector('[aria-hidden="true"] svg')).toBeTruthy(); + expect(container.querySelector('svg')).toBeTruthy(); }); }); diff --git a/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx b/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx index fd0b7852fcb..b3414ef80b4 100644 --- a/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx +++ b/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx @@ -25,7 +25,7 @@ import { PromptToken, PromptTokenField } from '../../src/PromptField'; -import {Attachment} from '../../src/AttachmentList'; +import {Attachment, AttachmentPreview} from '../../src/AttachmentList'; import { Collection, Header, @@ -36,7 +36,6 @@ import { SubmenuTrigger, Text } from '@react-spectrum/s2/Menu'; -import {Image} from '@react-spectrum/s2/Image'; import {pointerMap, render} from '@react-spectrum/test-utils-internal'; import React, {useEffect, useState} from 'react'; import {TokenFieldValue} from 'react-aria-components'; @@ -257,7 +256,7 @@ function ControlledPromptField(props: ControlledPromptFieldProps) { textValue={attachment.file.name} isInvalid={invalid} uploadProgress={uploadProgress}> - {attachment.image && } + {attachment.image && } )}