From 6c155363073ab49560833a779c15ad2817de648d Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Thu, 3 Sep 2026 09:58:24 -0700 Subject: [PATCH 1/7] add: AttachmentGrid component --- packages/@react-spectrum/ai/exports/index.ts | 2 + .../@react-spectrum/ai/src/AttachmentGrid.tsx | 155 ++++++++++++++++++ .../@react-spectrum/ai/src/AttachmentList.tsx | 11 +- .../ai/stories/AttachmentGrid.stories.tsx | 102 ++++++++++++ .../ai/stories/UserMessage.stories.tsx | 22 +++ .../ai/test/AttachmentGrid.test.tsx | 77 +++++++++ 6 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 packages/@react-spectrum/ai/src/AttachmentGrid.tsx create mode 100644 packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx create mode 100644 packages/@react-spectrum/ai/test/AttachmentGrid.test.tsx diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 3e92864f21e..5ba2822da33 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -1,5 +1,6 @@ export {Alert} from '../src/Alert'; export {Attachment, AttachmentList, AttachmentPreview} from '../src/AttachmentList'; +export {AttachmentGrid, AttachmentGridItem} from '../src/AttachmentGrid'; export {MessageFeedback} from '../src/MessageFeedback'; export {MessageSource, SourceList, SourceListItem} from '../src/MessageSource'; export {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; @@ -44,6 +45,7 @@ export type { AttachmentListProps, AttachmentPreviewProps } from '../src/AttachmentList'; +export type {AttachmentGridProps, AttachmentGridItemProps} from '../src/AttachmentGrid'; export type { PromptFieldProps, PromptFieldSubmitButtonProps, diff --git a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx new file mode 100644 index 00000000000..594e97b6020 --- /dev/null +++ b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx @@ -0,0 +1,155 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {AriaLabelingProps, DOMProps, DOMRef, forwardRefType} from '@react-types/shared'; +import { + AttachmentCard, + AttachmentPreviewContext, + AttachmentRenderProps, + isAttachmentLoading +} from './AttachmentList'; +import {css, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {forwardRef, ReactNode} from 'react'; +import {ListBox, ListBoxItem, ListBoxItemProps, ListBoxProps} from 'react-aria-components/ListBox'; +import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; +import {scrollFade} from './tokens.macro' with {type: 'macro'}; +import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {useDOMRef} from './useDOMRef'; + +export interface AttachmentGridProps + extends + DOMProps, + AriaLabelingProps, + Pick, 'items' | 'children' | 'dependencies'> { + /** + * Spectrum-defined styles, returned by the `style()` macro. + */ + styles?: StyleString; +} + +// Cards with title/description content (see AttachmentList.tsx's identical selector) need +// room for text, so they get a much wider column track than bare thumbnails. +const hasContent = ':has([data-slot=content])'; + +const gridStyles = style({ + display: 'grid', + gridTemplateColumns: { + default: 'repeat(auto-fill, minmax(64px, 1fr))', + [hasContent]: 'repeat(auto-fill, minmax(240px, 1fr))' + }, + maxHeight: 240, + overflowY: 'auto', + overflowX: 'clip', + scrollbarWidth: { + '@supports (animation-timeline: scroll())': 'none' + }, + boxSizing: 'border-box', + ...focusRing() +}); + +const gridGap = css('gap: 6px;'); + +/** + * An AttachmentGrid displays file attachments as a wrapping, vertically-scrolling grid of + * thumbnails. Unlike AttachmentList, it is display-only and does not support selection or removal. + * Every attachment is disabled, so the grid itself becomes the sole tab stop, keeping the + * overflow area keyboard-scrollable without letting individual attachments be focused or actioned. + */ +export const AttachmentGrid = (forwardRef as forwardRefType)(function AttachmentGrid( + props: AttachmentGridProps, + ref: DOMRef +) { + let {styles, items, children, dependencies, ...otherProps} = props; + let domRef = useDOMRef(ref); + + return ( + domRef.current?.focus()} + className={renderProps => + mergeStyles(gridStyles({...renderProps}), styles) + + ' ' + + gridGap + + ' ' + + scrollFade({y: 36}) + }> + {children} + + ); +}); + +export interface AttachmentGridItemProps + extends AriaLabelingProps, Pick { + /** The size of the Card. */ + size?: 'XS' | 'S' | 'M' | 'L' | 'XL'; + /** Whether the attachment has an error. */ + isInvalid?: boolean; + uploadProgress?: number; + /** The children of the AttachmentGridItem. */ + children: ReactNode | ((renderProps: AttachmentRenderProps) => ReactNode); + /** + * Spectrum-defined styles, returned by the `style()` macro. + */ + styles?: StyleString; +} + +const itemStyles = style({ + flexShrink: 0, + flexGrow: 0, + position: 'relative', + borderRadius: 'lg' +}); + +/** + * AttachmentGridItem displays an individual file attachment thumbnail within an AttachmentGrid. + */ +export const AttachmentGridItem = forwardRef(function AttachmentGridItem( + props: AttachmentGridItemProps, + ref: DOMRef +) { + let { + id, + textValue, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + styles, + isInvalid, + children, + size = 'M' + } = props; + let domRef = useDOMRef(ref); + let isLoading = isAttachmentLoading(props.uploadProgress); + + return ( + + + + {typeof children === 'function' ? children({size}) : children} + + + + ); +}); diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 41ef362819b..11ddfb2e83e 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -60,6 +60,11 @@ import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +/** Whether an attachment is still uploading, shared by Attachment and AttachmentGridItem. */ +export function isAttachmentLoading(uploadProgress?: number): boolean { + return uploadProgress != null && uploadProgress < 100; +} + const controlSizeM = { default: 32, size: { @@ -540,7 +545,7 @@ interface AttachmentCardProps { children: ReactNode; } -function AttachmentCard({ +export function AttachmentCard({ size = 'M', isInvalid = false, isLoading = false, @@ -620,7 +625,7 @@ export const Attachment = forwardRef(function Attachment( size = 'M' } = props; let domRef = useDOMRef(ref); - let isLoading = props.uploadProgress != null && props.uploadProgress < 100; + let isLoading = isAttachmentLoading(props.uploadProgress); return ( { + /** Number of demo attachments to render. */ + count: number; + /** Whether to show title/description content below the thumbnail. */ + showCardContent?: boolean; +} + +function AttachmentGridDemo({ + count, + isInvalid, + uploadProgress, + size, + showCardContent +}: AttachmentGridDemoProps) { + return ( + + {Array.from({length: count}, (_, i) => ( + + + {showCardContent && ( + + {`file-${i + 1}.pdf`} + PDF + + )} + + ))} + + ); +} + +const meta: Meta = { + component: AttachmentGridDemo, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + count: {table: {disable: true}}, + isInvalid: {control: 'boolean'}, + uploadProgress: {control: 'number', min: 0, max: 100}, + size: { + control: 'radio', + options: ['XS', 'S', 'M', 'L', 'XL'] + }, + showCardContent: {control: 'boolean'} + }, + args: {isInvalid: false, size: 'M', showCardContent: false}, + title: 'AI/AttachmentGrid' +}; + +export default meta; + +type Story = StoryObj; + +export const AIAttachmentGrid: Story = { + render: args => ( +
+ +
+ ) +}; + +export const Overflow: Story = { + name: 'Overflow (vertical scroll fade)', + render: args => ( +
+ +
+ ) +}; diff --git a/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx b/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx index c848346bc42..a1657a75c3b 100644 --- a/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx +++ b/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx @@ -11,6 +11,8 @@ */ import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; +import {AttachmentGrid, AttachmentGridItem} from '../src/AttachmentGrid'; +import {AttachmentPreview} from '../src/AttachmentList'; import {categorizeArgTypes} from '../../s2/stories/utils'; import {Heading} from '@react-spectrum/s2/Heading'; import {Image} from '@react-spectrum/s2/Image'; @@ -86,6 +88,26 @@ export const WithImage: Story = { ) }; +export const WithAttachmentGrid: Story = { + render: args => ( + +
+ + {Array.from({length: 20}, (_, i) => ( + + + + ))} + +
+
+ ) +}; + export const WithCard: Story = { render: args => (
diff --git a/packages/@react-spectrum/ai/test/AttachmentGrid.test.tsx b/packages/@react-spectrum/ai/test/AttachmentGrid.test.tsx new file mode 100644 index 00000000000..4faf0017737 --- /dev/null +++ b/packages/@react-spectrum/ai/test/AttachmentGrid.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, fireEvent, render} from '@react-spectrum/test-utils-internal'; +import {AttachmentGrid, AttachmentGridItem} from '@react-spectrum/ai'; +import {Image} from '@react-spectrum/s2/Image'; +import React from 'react'; + +// Conditionally skip the suite +const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; +describeOrSkip('AttachmentGrid', () => { + it('should render as a non-interactive grid whose items are not focusable', () => { + let {getByRole, getAllByRole} = render( + + + + + + + + + ); + + // All options are disabled, so the grid itself becomes the sole tab stop, keeping the + // overflow area keyboard-scrollable even though no individual attachment is focusable. + let grid = getByRole('listbox'); + expect(grid).toBeInTheDocument(); + expect(grid).toHaveAttribute('tabIndex', '0'); + let options = getAllByRole('option'); + expect(options).toHaveLength(2); + for (let option of options) { + expect(option).toHaveAttribute('aria-disabled', 'true'); + expect(option).not.toHaveAttribute('tabIndex'); + } + expect(grid).not.toHaveAttribute('aria-multiselectable'); + }); + + it('should not intercept arrow keys, so the browser can natively scroll the grid', () => { + let {getByRole} = render( + + + + + + ); + + let grid = getByRole('listbox'); + act(() => grid.focus()); + expect(fireEvent.keyDown(grid, {key: 'ArrowDown'})).toBe(true); + expect(fireEvent.keyDown(grid, {key: 'ArrowUp'})).toBe(true); + }); + + it('should focus the grid when a disabled attachment is clicked', () => { + let {getByRole} = render( + + + + + + ); + + let grid = getByRole('listbox'); + let option = getByRole('option'); + expect(grid).not.toHaveFocus(); + fireEvent.pointerDown(option); + expect(grid).toHaveFocus(); + }); +}); From a13082490da1b4c86e05b93ec26415cdbe681b43 Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Thu, 3 Sep 2026 10:31:44 -0700 Subject: [PATCH 2/7] fix: remove extra focus --- .../@react-spectrum/ai/src/AttachmentGrid.tsx | 1 - .../ai/test/AttachmentGrid.test.tsx | 16 ---------------- 2 files changed, 17 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx index 594e97b6020..8d8572d486b 100644 --- a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx @@ -78,7 +78,6 @@ export const AttachmentGrid = (forwardRef as forwardRefType)(function Attachment items={items} dependencies={dependencies} ref={domRef} - onPointerDown={() => domRef.current?.focus()} className={renderProps => mergeStyles(gridStyles({...renderProps}), styles) + ' ' + diff --git a/packages/@react-spectrum/ai/test/AttachmentGrid.test.tsx b/packages/@react-spectrum/ai/test/AttachmentGrid.test.tsx index 4faf0017737..eeaa3ef8b1e 100644 --- a/packages/@react-spectrum/ai/test/AttachmentGrid.test.tsx +++ b/packages/@react-spectrum/ai/test/AttachmentGrid.test.tsx @@ -58,20 +58,4 @@ describeOrSkip('AttachmentGrid', () => { expect(fireEvent.keyDown(grid, {key: 'ArrowDown'})).toBe(true); expect(fireEvent.keyDown(grid, {key: 'ArrowUp'})).toBe(true); }); - - it('should focus the grid when a disabled attachment is clicked', () => { - let {getByRole} = render( - - - - - - ); - - let grid = getByRole('listbox'); - let option = getByRole('option'); - expect(grid).not.toHaveFocus(); - fireEvent.pointerDown(option); - expect(grid).toHaveFocus(); - }); }); From 230b3131b281ee5a3fce8e6ebc1e3fb3e071962c Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Thu, 3 Sep 2026 10:45:49 -0700 Subject: [PATCH 3/7] fix: remove padding on story --- .../@react-spectrum/ai/stories/AttachmentGrid.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx index 66e1b25ca16..4d69379cb68 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx @@ -86,7 +86,7 @@ type Story = StoryObj; export const AIAttachmentGrid: Story = { render: args => ( -
+
) @@ -95,7 +95,7 @@ export const AIAttachmentGrid: Story = { export const Overflow: Story = { name: 'Overflow (vertical scroll fade)', render: args => ( -
+
) From a9f231b45d7b772f6f89d1b91c7d8779987f2334 Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Wed, 9 Sep 2026 09:58:10 -0700 Subject: [PATCH 4/7] fix: add scrollbar + try gap fixes --- .../@react-spectrum/ai/src/AttachmentGrid.tsx | 50 +++++++++---------- .../ai/stories/AttachmentGrid.stories.tsx | 15 +++--- .../ai/stories/UserMessage.stories.tsx | 4 +- 3 files changed, 30 insertions(+), 39 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx index 8d8572d486b..746b8491a52 100644 --- a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx @@ -10,20 +10,23 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMProps, DOMRef, forwardRefType} from '@react-types/shared'; +import {AriaLabelingProps, DOMAttributes, DOMProps, DOMRef, forwardRefType} from '@react-types/shared'; import { AttachmentCard, AttachmentPreviewContext, AttachmentRenderProps, isAttachmentLoading } from './AttachmentList'; -import {css, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {forwardRef, ReactNode} from 'react'; +import {CSSProperties, forwardRef, ReactNode} from 'react'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {ListBox, ListBoxItem, ListBoxItemProps, ListBoxProps} from 'react-aria-components/ListBox'; +import {mergeProps} from 'react-aria/mergeProps'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {scrollFade} from './tokens.macro' with {type: 'macro'}; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; +import {useHover} from 'react-aria/useHover'; export interface AttachmentGridProps extends @@ -41,23 +44,24 @@ export interface AttachmentGridProps const hasContent = ':has([data-slot=content])'; const gridStyles = style({ - display: 'grid', + display: { + default: 'flex', + [hasContent]: 'grid' + }, + flexWrap: 'wrap', + alignItems: 'start', gridTemplateColumns: { - default: 'repeat(auto-fill, minmax(64px, 1fr))', [hasContent]: 'repeat(auto-fill, minmax(240px, 1fr))' }, + gap: 8, maxHeight: 240, overflowY: 'auto', overflowX: 'clip', - scrollbarWidth: { - '@supports (animation-timeline: scroll())': 'none' - }, + scrollbarWidth: 'thin', boxSizing: 'border-box', ...focusRing() }); -const gridGap = css('gap: 6px;'); - /** * An AttachmentGrid displays file attachments as a wrapping, vertically-scrolling grid of * thumbnails. Unlike AttachmentList, it is display-only and does not support selection or removal. @@ -70,19 +74,23 @@ export const AttachmentGrid = (forwardRef as forwardRefType)(function Attachment ) { let {styles, items, children, dependencies, ...otherProps} = props; let domRef = useDOMRef(ref); + let {hoverProps, isHovered} = useHover({}); return ( )} layout="grid" items={items} dependencies={dependencies} ref={domRef} + style={{ + scrollbarColor: isHovered + ? 'light-dark(rgb(0 0 0 / 30%), rgb(255 255 255 / 30%)) transparent' + : 'transparent transparent' + } as CSSProperties} className={renderProps => mergeStyles(gridStyles({...renderProps}), styles) + ' ' + - gridGap + - ' ' + scrollFade({y: 36}) }> {children} @@ -119,27 +127,15 @@ export const AttachmentGridItem = forwardRef(function AttachmentGridItem( props: AttachmentGridItemProps, ref: DOMRef ) { - let { - id, - textValue, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby, - 'aria-describedby': ariaDescribedby, - styles, - isInvalid, - children, - size = 'M' - } = props; + let {id, textValue, styles, isInvalid, size = 'M', children, ...otherProps} = props; let domRef = useDOMRef(ref); let isLoading = isAttachmentLoading(props.uploadProgress); return ( diff --git a/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx index 4d69379cb68..162a7588eec 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx @@ -31,8 +31,8 @@ function AttachmentGridDemo({ count, isInvalid, uploadProgress, - size, - showCardContent + showCardContent, + size }: AttachmentGridDemoProps) { return ( @@ -70,13 +70,10 @@ const meta: Meta = { count: {table: {disable: true}}, isInvalid: {control: 'boolean'}, uploadProgress: {control: 'number', min: 0, max: 100}, - size: { - control: 'radio', - options: ['XS', 'S', 'M', 'L', 'XL'] - }, - showCardContent: {control: 'boolean'} + showCardContent: {control: 'boolean'}, + size: {control: 'select', options: ['XS', 'S', 'M', 'L', 'XL']} }, - args: {isInvalid: false, size: 'M', showCardContent: false}, + args: {isInvalid: false, showCardContent: false, size: 'M'}, title: 'AI/AttachmentGrid' }; @@ -95,7 +92,7 @@ export const AIAttachmentGrid: Story = { export const Overflow: Story = { name: 'Overflow (vertical scroll fade)', render: args => ( -
+
) diff --git a/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx b/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx index a1657a75c3b..e36dc628450 100644 --- a/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx +++ b/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx @@ -90,8 +90,7 @@ export const WithImage: Story = { export const WithAttachmentGrid: Story = { render: args => ( - -
+ {Array.from({length: 20}, (_, i) => ( @@ -103,7 +102,6 @@ export const WithAttachmentGrid: Story = { ))} -
) }; From aea82f2d56917100524a465575265c0bf7acd087 Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Wed, 9 Sep 2026 10:32:43 -0700 Subject: [PATCH 5/7] fix: lint + remove custom scroll bars --- .../@react-spectrum/ai/src/AttachmentGrid.tsx | 19 ++++------------ .../ai/stories/UserMessage.stories.tsx | 22 +++++++++---------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx index 746b8491a52..e46f19735e6 100644 --- a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx @@ -10,23 +10,21 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMAttributes, DOMProps, DOMRef, forwardRefType} from '@react-types/shared'; +import {AriaLabelingProps, DOMProps, DOMRef, forwardRefType} from '@react-types/shared'; import { AttachmentCard, AttachmentPreviewContext, AttachmentRenderProps, isAttachmentLoading } from './AttachmentList'; -import {CSSProperties, forwardRef, ReactNode} from 'react'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {forwardRef, ReactNode} from 'react'; import {ListBox, ListBoxItem, ListBoxItemProps, ListBoxProps} from 'react-aria-components/ListBox'; -import {mergeProps} from 'react-aria/mergeProps'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {scrollFade} from './tokens.macro' with {type: 'macro'}; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; -import {useHover} from 'react-aria/useHover'; export interface AttachmentGridProps extends @@ -57,7 +55,6 @@ const gridStyles = style({ maxHeight: 240, overflowY: 'auto', overflowX: 'clip', - scrollbarWidth: 'thin', boxSizing: 'border-box', ...focusRing() }); @@ -74,24 +71,16 @@ export const AttachmentGrid = (forwardRef as forwardRefType)(function Attachment ) { let {styles, items, children, dependencies, ...otherProps} = props; let domRef = useDOMRef(ref); - let {hoverProps, isHovered} = useHover({}); return ( )} + {...otherProps} layout="grid" items={items} dependencies={dependencies} ref={domRef} - style={{ - scrollbarColor: isHovered - ? 'light-dark(rgb(0 0 0 / 30%), rgb(255 255 255 / 30%)) transparent' - : 'transparent transparent' - } as CSSProperties} className={renderProps => - mergeStyles(gridStyles({...renderProps}), styles) + - ' ' + - scrollFade({y: 36}) + mergeStyles(gridStyles({...renderProps}), styles) + ' ' + scrollFade({y: 36}) }> {children} diff --git a/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx b/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx index e36dc628450..d70ad337f07 100644 --- a/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx +++ b/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx @@ -91,17 +91,17 @@ export const WithImage: Story = { export const WithAttachmentGrid: Story = { render: args => ( - - {Array.from({length: 20}, (_, i) => ( - - - - ))} - + + {Array.from({length: 20}, (_, i) => ( + + + + ))} + ) }; From c9651b5a902775f13bf9baaec1d535790b828fd0 Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Thu, 10 Sep 2026 14:54:09 -0700 Subject: [PATCH 6/7] fix: align + remove UserMessage story --- .../@react-spectrum/ai/src/AttachmentGrid.tsx | 20 +++++++++++--- .../ai/stories/AttachmentGrid.stories.tsx | 26 ++++++++++++------- .../ai/stories/UserMessage.stories.tsx | 20 -------------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx index e46f19735e6..78c5d8a18ae 100644 --- a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx @@ -31,14 +31,19 @@ export interface AttachmentGridProps DOMProps, AriaLabelingProps, Pick, 'items' | 'children' | 'dependencies'> { + /** + * The alignment of attachments within the grid. + * + * @default 'start' + */ + align?: 'start' | 'center' | 'end'; /** * Spectrum-defined styles, returned by the `style()` macro. */ styles?: StyleString; } -// Cards with title/description content (see AttachmentList.tsx's identical selector) need -// room for text, so they get a much wider column track than bare thumbnails. +// Grid items are expected to be either all thumbnails or cards and the same size. const hasContent = ':has([data-slot=content])'; const gridStyles = style({ @@ -51,6 +56,13 @@ const gridStyles = style({ gridTemplateColumns: { [hasContent]: 'repeat(auto-fill, minmax(240px, 1fr))' }, + justifyContent: { + align: { + start: 'normal', + center: 'center', + end: 'end' + } + }, gap: 8, maxHeight: 240, overflowY: 'auto', @@ -69,7 +81,7 @@ export const AttachmentGrid = (forwardRef as forwardRefType)(function Attachment props: AttachmentGridProps, ref: DOMRef ) { - let {styles, items, children, dependencies, ...otherProps} = props; + let {styles, items, children, dependencies, align = 'start', ...otherProps} = props; let domRef = useDOMRef(ref); return ( @@ -80,7 +92,7 @@ export const AttachmentGrid = (forwardRef as forwardRefType)(function Attachment dependencies={dependencies} ref={domRef} className={renderProps => - mergeStyles(gridStyles({...renderProps}), styles) + ' ' + scrollFade({y: 36}) + mergeStyles(gridStyles({...renderProps, align}), styles) + ' ' + scrollFade({y: 36}) }> {children} diff --git a/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx index 162a7588eec..7924fd2428f 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentGrid.stories.tsx @@ -10,17 +10,22 @@ * governing permissions and limitations under the License. */ -import {AttachmentGrid, AttachmentGridItem, AttachmentGridItemProps} from '../src/AttachmentGrid'; +import { + AttachmentGrid, + AttachmentGridItem, + AttachmentGridItemProps, + AttachmentGridProps +} from '../src/AttachmentGrid'; import {AttachmentPreview} from '../src/AttachmentList'; import {Content} from '@react-spectrum/s2/Content'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Text} from '@react-spectrum/s2/Text'; -interface AttachmentGridDemoProps extends Pick< - AttachmentGridItemProps, - 'isInvalid' | 'uploadProgress' | 'size' -> { +interface AttachmentGridDemoProps + extends + Pick, + Pick, 'align'> { /** Number of demo attachments to render. */ count: number; /** Whether to show title/description content below the thumbnail. */ @@ -32,10 +37,11 @@ function AttachmentGridDemo({ isInvalid, uploadProgress, showCardContent, - size + size, + align }: AttachmentGridDemoProps) { return ( - + {Array.from({length: count}, (_, i) => ( = { component: AttachmentGridDemo, + subcomponents: {AttachmentGrid, AttachmentGridItem}, parameters: { layout: 'centered' }, @@ -71,9 +78,10 @@ const meta: Meta = { isInvalid: {control: 'boolean'}, uploadProgress: {control: 'number', min: 0, max: 100}, showCardContent: {control: 'boolean'}, - size: {control: 'select', options: ['XS', 'S', 'M', 'L', 'XL']} + size: {control: 'select', options: ['XS', 'S', 'M', 'L', 'XL']}, + align: {control: 'select', options: ['start', 'center', 'end']} }, - args: {isInvalid: false, showCardContent: false, size: 'M'}, + args: {isInvalid: false, showCardContent: false, size: 'M', align: 'start'}, title: 'AI/AttachmentGrid' }; diff --git a/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx b/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx index d70ad337f07..c848346bc42 100644 --- a/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx +++ b/packages/@react-spectrum/ai/stories/UserMessage.stories.tsx @@ -11,8 +11,6 @@ */ import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; -import {AttachmentGrid, AttachmentGridItem} from '../src/AttachmentGrid'; -import {AttachmentPreview} from '../src/AttachmentList'; import {categorizeArgTypes} from '../../s2/stories/utils'; import {Heading} from '@react-spectrum/s2/Heading'; import {Image} from '@react-spectrum/s2/Image'; @@ -88,24 +86,6 @@ export const WithImage: Story = { ) }; -export const WithAttachmentGrid: Story = { - render: args => ( - - - {Array.from({length: 20}, (_, i) => ( - - - - ))} - - - ) -}; - export const WithCard: Story = { render: args => (
From 6edbbf89beef6c20b802e06ef9ec322298e813e2 Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Fri, 11 Sep 2026 09:56:07 -0700 Subject: [PATCH 7/7] fix: remove renderprops --- packages/@react-spectrum/ai/src/AttachmentGrid.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx index 78c5d8a18ae..fca7aaa06ad 100644 --- a/packages/@react-spectrum/ai/src/AttachmentGrid.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx @@ -11,12 +11,7 @@ */ import {AriaLabelingProps, DOMProps, DOMRef, forwardRefType} from '@react-types/shared'; -import { - AttachmentCard, - AttachmentPreviewContext, - AttachmentRenderProps, - isAttachmentLoading -} from './AttachmentList'; +import {AttachmentCard, AttachmentPreviewContext, isAttachmentLoading} from './AttachmentList'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {forwardRef, ReactNode} from 'react'; @@ -107,7 +102,7 @@ export interface AttachmentGridItemProps isInvalid?: boolean; uploadProgress?: number; /** The children of the AttachmentGridItem. */ - children: ReactNode | ((renderProps: AttachmentRenderProps) => ReactNode); + children: ReactNode; /** * Spectrum-defined styles, returned by the `style()` macro. */ @@ -143,7 +138,7 @@ export const AttachmentGridItem = forwardRef(function AttachmentGridItem( - {typeof children === 'function' ? children({size}) : children} + {children}