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..8d8572d486b --- /dev/null +++ b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx @@ -0,0 +1,154 @@ +/* + * 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 ( + + 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 d19efd7ed33..35f0678cbcf 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -60,11 +60,16 @@ import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; -interface AttachmentRenderProps { +export interface AttachmentRenderProps { /** The size of the Card. */ size: 'XS' | 'S' | 'M' | 'L' | 'XL'; } +/** 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: { @@ -545,7 +550,7 @@ interface AttachmentCardProps { children: ReactNode; } -function AttachmentCard({ +export function AttachmentCard({ size = 'M', isInvalid = false, isLoading = false, @@ -625,7 +630,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..eeaa3ef8b1e --- /dev/null +++ b/packages/@react-spectrum/ai/test/AttachmentGrid.test.tsx @@ -0,0 +1,61 @@ +/* + * 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); + }); +});