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..fca7aaa06ad --- /dev/null +++ b/packages/@react-spectrum/ai/src/AttachmentGrid.tsx @@ -0,0 +1,146 @@ +/* + * 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, 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'; +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'> { + /** + * The alignment of attachments within the grid. + * + * @default 'start' + */ + align?: 'start' | 'center' | 'end'; + /** + * Spectrum-defined styles, returned by the `style()` macro. + */ + styles?: StyleString; +} + +// Grid items are expected to be either all thumbnails or cards and the same size. +const hasContent = ':has([data-slot=content])'; + +const gridStyles = style({ + display: { + default: 'flex', + [hasContent]: 'grid' + }, + flexWrap: 'wrap', + alignItems: 'start', + gridTemplateColumns: { + [hasContent]: 'repeat(auto-fill, minmax(240px, 1fr))' + }, + justifyContent: { + align: { + start: 'normal', + center: 'center', + end: 'end' + } + }, + gap: 8, + maxHeight: 240, + overflowY: 'auto', + overflowX: 'clip', + boxSizing: 'border-box', + ...focusRing() +}); + +/** + * 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, align = 'start', ...otherProps} = props; + let domRef = useDOMRef(ref); + + return ( + + mergeStyles(gridStyles({...renderProps, align}), styles) + ' ' + 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; + /** + * 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, styles, isInvalid, size = 'M', children, ...otherProps} = props; + let domRef = useDOMRef(ref); + let isLoading = isAttachmentLoading(props.uploadProgress); + + return ( + + + + {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 ( , + Pick, 'align'> { + /** Number of demo attachments to render. */ + count: number; + /** Whether to show title/description content below the thumbnail. */ + showCardContent?: boolean; +} + +function AttachmentGridDemo({ + count, + isInvalid, + uploadProgress, + showCardContent, + size, + align +}: AttachmentGridDemoProps) { + return ( + + {Array.from({length: count}, (_, i) => ( + + + {showCardContent && ( + + {`file-${i + 1}.pdf`} + PDF + + )} + + ))} + + ); +} + +const meta: Meta = { + component: AttachmentGridDemo, + subcomponents: {AttachmentGrid, AttachmentGridItem}, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + count: {table: {disable: true}}, + isInvalid: {control: 'boolean'}, + uploadProgress: {control: 'number', min: 0, max: 100}, + showCardContent: {control: 'boolean'}, + size: {control: 'select', options: ['XS', 'S', 'M', 'L', 'XL']}, + align: {control: 'select', options: ['start', 'center', 'end']} + }, + args: {isInvalid: false, showCardContent: false, size: 'M', align: 'start'}, + 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/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); + }); +});