Skip to content

feat: add AttachmentGrid component - #10561

Open
DPandyan wants to merge 5 commits into
mainfrom
attachmentgrid
Open

feat: add AttachmentGrid component#10561
DPandyan wants to merge 5 commits into
mainfrom
attachmentgrid

Conversation

@DPandyan

@DPandyan DPandyan commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Closes

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices
  • I understand every change in this PR and can explain why it's there.
  • If AI-assisted, I followed our AI contribution guidance and pointed my assistant at CLAUDE.md.

📝 Test Instructions:

🧢 Your Project:

Adobe

@DPandyan DPandyan changed the title add: AttachmentGrid component feat: add AttachmentGrid component Sep 3, 2026
@rspbot

rspbot commented Sep 3, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Sep 3, 2026

Copy link
Copy Markdown

...focusRing()
});

const gridGap = css('gap: 6px;');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can swap this out for 8px, Figma had 6, wasn't sure.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can use custom values in a style macro like so
gap: '[6px]'
or if it needs to respond to font size or scaling, you can make use of size() or space()
https://react-spectrum.adobe.com/style-macro#space

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it really a static 6px at all sizes? Feels like the scale should change the gap size

)
};

export const WithAttachmentGrid: Story = {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought this would be a useful story, can remove.

export const Overflow: Story = {
name: 'Overflow (vertical scroll fade)',
render: args => (
<div style={{width: 320, resize: 'horizontal', overflow: 'hidden'}}>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this needs padding

@LFDanLu LFDanLu added the release label Sep 3, 2026
@rspbot

rspbot commented Sep 3, 2026

Copy link
Copy Markdown

@DPandyan
DPandyan marked this pull request as ready for review September 3, 2026 17:55
...focusRing()
});

const gridGap = css('gap: 6px;');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it really a static 6px at all sizes? Feels like the scale should change the gap size

let domRef = useDOMRef(ref);

return (
<ListBox

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the container have a focus ring? Or are the items focusable its hard to tell

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the individual items should be focusable/interactable but perhaps the grid/container itself should have a focus ring. You are supposed to be able to scroll it with keyboard when focused.

const hasContent = ':has([data-slot=content])';

const gridStyles = style({
display: 'grid',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can there be a mix of cards that are thumbnails and cards that have the description?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, the card types can't be mixed (at least following AttachmentList)

const gridStyles = style({
display: 'grid',
gridTemplateColumns: {
default: 'repeat(auto-fill, minmax(64px, 1fr))',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the container is wide and there aren't enough thumbs to fill it in the first row, i think they'll end up spaced apart
I think you just want

default: 'repeat(auto-fill, minmax(64px, auto))'

though that only matters if the attachments can be varying sizes, if they can't, then you could just do

default: 'repeat(auto-fill, 64px)'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had a discussion about this in one of our team meetings. We're unsure if it should be justified space around as you have it here, or left justified... or what, can you follow up with the designer?

size?: 'XS' | 'S' | 'M' | 'L' | 'XL';
/** Whether the attachment has an error. */
isInvalid?: boolean;
uploadProgress?: number;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing description

Comment on lines +140 to +142
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these and id are all just getting passed straight through, we can use filterDOMProps with labeling set to true
then we can just spread the result of that

@LFDanLu LFDanLu removed the release label Sep 3, 2026
@rspbot

rspbot commented Sep 9, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Sep 9, 2026

Copy link
Copy Markdown
## API Changes

@react-spectrum/ai

/@react-spectrum/ai:Attachment

 Attachment {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
-  children: ReactNode
+  children: ReactNode | (AttachmentRenderProps) => ReactNode
   density?: 'compact' | 'regular' | 'spacious' = 'regular'
   download?: boolean | string
   href?: Href
   hrefLang?: string
   isDisabled?: boolean
   isInvalid?: boolean
   onAction?: () => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TagRenderProps>
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StyleString
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   uploadProgress?: number
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' = 'primary'
 }

/@react-spectrum/ai:PromptFieldValue

 PromptFieldValue {
   Direction: any
   SelectedRange: any
   caretPosition: Position
   constructor: (readonly Array<TokenFieldSegment<T>>, TokenFieldValueOptions) => void
   delete: (Position, Intl.Segmenter, any, any) => this
   deleteLine: (Position, any, any) => this
   endCoalescing: () => void
   findBoundaryWithSegmenter: (Position, Intl.Segmenter, any) => Position | null
   findLineBoundary: (Position, any) => Position | null
   findText: (Position, any, string | RegExp) => Position | null
   redo: () => this
   replaceRange: (Position, Position, string, any) => this
   replaceRangeWithSegments: (Position, Position, Array<TokenFieldSegment>, any) => this
   segments: readonly Array<TokenFieldSegment<T>>
   selectedRange: SelectedRange
   slice: (Position, Position) => this
-  toString: () => void
+  toString: () => string
   tokenize: (string) => Array<TokenFieldSegment>
   undo: () => this
   withCaretPosition: (Position) => this
   withSelectedRange: (SelectedRange) => this

/@react-spectrum/ai:PromptField

 PromptField {
   acceptedAttachmentTypes?: Array<string>
-  aiDisclaimer?: React.ReactNode
   attachments?: Array<PromptFieldAttachment>
   brandColor?: string
   children: React.ReactNode
   defaultAttachments?: Array<PromptFieldAttachment>
   isGenerating?: boolean
   onAITermsPress?: () => void
   onAddAttachments?: (Array<PromptFieldAttachment>) => void
   onAttachmentsChange?: (Array<PromptFieldAttachment>) => void
   onChange?: (PromptFieldValue) => void
   onRemoveAttachments?: (Array<PromptFieldAttachment>) => void
   onStop?: () => void
   onSubmit?: (PromptFieldValue, Array<PromptFieldAttachment>) => void
   size?: 'S' | 'M' = 'M'
   styles?: StyleString
   value?: PromptFieldValue
   variant?: 'balanced' | 'prominent' | 'subtle' = 'balanced'
 }

/@react-spectrum/ai:PromptToken

 PromptToken {
   children: React.ReactNode
+  className?: ClassNameOrFunction<TokenRenderProps> = 'react-aria-Token'
+  style?: StyleOrFunction<TokenRenderProps>
   token: TokenSegment<PromptFieldTokenValue>
 }

/@react-spectrum/ai:ExecutionTraceItem

 ExecutionTraceItem {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
-  children: string
+  children: ReactNode
   detail?: ReactNode
   detailMaxHeight?: number = 120
   icon?: ReactNode
   id?: string
   styles?: StyleString
 }

/@react-spectrum/ai:ResponseStatusTitle

 ResponseStatusTitle {
-  children: string
+  children: React.ReactNode
   id?: string
   level?: number = 3
   pixelLoader?: Array<Cell> | Array<Array<Cell>>
   styles?: StyleString

/@react-spectrum/ai:ThreadItem

 ThreadItem {
   allowsArrowNavigation?: boolean
-  children: ReactNode
+  children?: ChildrenOrFunction<GridListItemRenderProps>
   focusMode?: 'child' | 'row'
   id?: Key
   isStreaming?: boolean
   shouldAnnounceOnMount?: boolean
   textValue?: string
 }

/@react-spectrum/ai:AttachmentProps

 AttachmentProps {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
-  children: ReactNode
+  children: ReactNode | (AttachmentRenderProps) => ReactNode
   density?: 'compact' | 'regular' | 'spacious' = 'regular'
   download?: boolean | string
   href?: Href
   hrefLang?: string
   isDisabled?: boolean
   isInvalid?: boolean
   onAction?: () => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TagRenderProps>
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StyleString
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   uploadProgress?: number
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' = 'primary'
 }

/@react-spectrum/ai:PromptFieldProps

 PromptFieldProps {
   acceptedAttachmentTypes?: Array<string>
-  aiDisclaimer?: React.ReactNode
   attachments?: Array<PromptFieldAttachment>
   brandColor?: string
   children: React.ReactNode
   defaultAttachments?: Array<PromptFieldAttachment>
   isGenerating?: boolean
   onAITermsPress?: () => void
   onAddAttachments?: (Array<PromptFieldAttachment>) => void
   onAttachmentsChange?: (Array<PromptFieldAttachment>) => void
   onChange?: (PromptFieldValue) => void
   onRemoveAttachments?: (Array<PromptFieldAttachment>) => void
   onStop?: () => void
   onSubmit?: (PromptFieldValue, Array<PromptFieldAttachment>) => void
   size?: 'S' | 'M' = 'M'
   styles?: StyleString
   value?: PromptFieldValue
   variant?: 'balanced' | 'prominent' | 'subtle' = 'balanced'
 }

/@react-spectrum/ai:PromptTokenProps

 PromptTokenProps {
   children: React.ReactNode
+  className?: ClassNameOrFunction<TokenRenderProps> = 'react-aria-Token'
+  style?: StyleOrFunction<TokenRenderProps>
   token: TokenSegment<PromptFieldTokenValue>
 }

/@react-spectrum/ai:ExecutionTraceItemProps

 ExecutionTraceItemProps {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
-  children: string
+  children: ReactNode
   detail?: ReactNode
   detailMaxHeight?: number = 120
   icon?: ReactNode
   id?: string
   styles?: StyleString
 }

/@react-spectrum/ai:ResponseStatusTitleProps

 ResponseStatusTitleProps {
-  children: string
+  children: React.ReactNode
   id?: string
   level?: number = 3
   pixelLoader?: Array<Cell> | Array<Array<Cell>>
   styles?: StyleString

/@react-spectrum/ai:ThreadItemProps

 ThreadItemProps {
   allowsArrowNavigation?: boolean
-  children: ReactNode
+  children?: ChildrenOrFunction<GridListItemRenderProps>
   focusMode?: 'child' | 'row'
   id?: Key
   isStreaming?: boolean
   shouldAnnounceOnMount?: boolean
   textValue?: string
 }

/@react-spectrum/ai:AttachmentGrid

+AttachmentGrid <T> {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode | (T) => ReactNode
+  dependencies?: ReadonlyArray<any>
+  id?: string
+  items?: Iterable<T>
+  styles?: StyleString
+}

/@react-spectrum/ai:AttachmentGridItem

+AttachmentGridItem {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children: ReactNode | (AttachmentRenderProps) => ReactNode
+  id?: Key
+  isInvalid?: boolean
+  size?: 'XS' | 'S' | 'M' | 'L' | 'XL'
+  styles?: StyleString
+  textValue?: string
+  uploadProgress?: number
+}

/@react-spectrum/ai:AttachmentGridProps

+AttachmentGridProps <T> {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode | (T) => ReactNode
+  dependencies?: ReadonlyArray<any>
+  id?: string
+  items?: Iterable<T>
+  styles?: StyleString
+}

/@react-spectrum/ai:AttachmentGridItemProps

+AttachmentGridItemProps {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children: ReactNode | (AttachmentRenderProps) => ReactNode
+  id?: Key
+  isInvalid?: boolean
+  size?: 'XS' | 'S' | 'M' | 'L' | 'XL'
+  styles?: StyleString
+  textValue?: string
+  uploadProgress?: number
+}

@react-spectrum/s2

/@react-spectrum/s2:Menu

 Menu <T> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children: ReactNode | (T) => ReactNode
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   hideLinkOutIcon?: boolean
   id?: string
   items?: Iterable<T>
-  loadingState?: LoadingState
   onAction?: (Key, T) => void
   onClose?: () => void
-  onLoadMore?: () => any
   onSelectionChange?: (Selection) => void
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   shouldCloseOnSelect?: boolean
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   styles?: StylesProp
 }

/@react-spectrum/s2:MenuProps

 MenuProps <T> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children: ReactNode | (T) => ReactNode
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   hideLinkOutIcon?: boolean
   id?: string
   items?: Iterable<T>
-  loadingState?: LoadingState
   onAction?: (Key, T) => void
   onClose?: () => void
-  onLoadMore?: () => any
   onSelectionChange?: (Selection) => void
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   shouldCloseOnSelect?: boolean
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   styles?: StylesProp
 }

@rspbot

rspbot commented Sep 9, 2026

Copy link
Copy Markdown

Agent Skills Changes

Modified (12)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/910cd9a496abe3c40c3395b3104027c7cb352c38/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/910cd9a496abe3c40c3395b3104027c7cb352c38/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants