-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat: add AttachmentGrid component #10561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T> | ||
| extends | ||
| DOMProps, | ||
| AriaLabelingProps, | ||
| Pick<ListBoxProps<T>, '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))', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 though that only matters if the attachments can be varying sizes, if they can't, then you could just do |
||
| [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;'); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you can use custom values in a style macro like so
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| /** | ||
| * 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<T>( | ||
| props: AttachmentGridProps<T>, | ||
| ref: DOMRef<HTMLDivElement> | ||
| ) { | ||
| let {styles, items, children, dependencies, ...otherProps} = props; | ||
| let domRef = useDOMRef(ref); | ||
|
|
||
| return ( | ||
| <ListBox | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| {...otherProps} | ||
| layout="grid" | ||
| items={items} | ||
| dependencies={dependencies} | ||
| ref={domRef} | ||
| className={renderProps => | ||
| mergeStyles(gridStyles({...renderProps}), styles) + | ||
| ' ' + | ||
| gridGap + | ||
| ' ' + | ||
| scrollFade({y: 36}) | ||
| }> | ||
| {children} | ||
| </ListBox> | ||
| ); | ||
| }); | ||
|
|
||
| export interface AttachmentGridItemProps | ||
| extends AriaLabelingProps, Pick<ListBoxItemProps, 'id' | 'textValue'> { | ||
| /** The size of the Card. */ | ||
| size?: 'XS' | 'S' | 'M' | 'L' | 'XL'; | ||
| /** Whether the attachment has an error. */ | ||
| isInvalid?: boolean; | ||
| uploadProgress?: number; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. missing description |
||
| /** 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<HTMLDivElement> | ||
| ) { | ||
| 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 ( | ||
| <ListBoxItem | ||
| id={id} | ||
| textValue={textValue} | ||
| aria-label={ariaLabel} | ||
| aria-labelledby={ariaLabelledby} | ||
| aria-describedby={ariaDescribedby} | ||
|
Comment on lines
+140
to
+142
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| isDisabled | ||
| ref={domRef} | ||
| className={mergeStyles(itemStyles, styles)}> | ||
| <AttachmentCard size={size} isInvalid={isInvalid} isLoading={isLoading}> | ||
| <AttachmentPreviewContext.Provider | ||
| value={{isInvalid: !!isInvalid, uploadProgress: props.uploadProgress ?? 100, size}}> | ||
| {typeof children === 'function' ? children({size}) : children} | ||
| </AttachmentPreviewContext.Provider> | ||
| </AttachmentCard> | ||
| </ListBoxItem> | ||
| ); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| /* | ||
| * 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 {AttachmentGrid, AttachmentGridItem, AttachmentGridItemProps} 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' | ||
| > { | ||
| /** 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 ( | ||
| <AttachmentGrid aria-label="Uploaded files" styles={style({width: 'full'})}> | ||
| {Array.from({length: count}, (_, i) => ( | ||
| <AttachmentGridItem | ||
| key={i} | ||
| uploadProgress={uploadProgress} | ||
| isInvalid={isInvalid} | ||
| size={size} | ||
| aria-label={`file-${i + 1}.pdf`}> | ||
| <AttachmentPreview | ||
| mimeType="application/pdf" | ||
| slot="thumbnail" | ||
| src={new URL('../../s2/stories/assets/placeholder.png', import.meta.url).toString()} | ||
| /> | ||
| {showCardContent && ( | ||
| <Content> | ||
| <Text slot="title">{`file-${i + 1}.pdf`}</Text> | ||
| <Text slot="description">PDF</Text> | ||
| </Content> | ||
| )} | ||
| </AttachmentGridItem> | ||
| ))} | ||
| </AttachmentGrid> | ||
| ); | ||
| } | ||
|
|
||
| const meta: Meta<typeof AttachmentGridDemo> = { | ||
| 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<typeof AttachmentGridDemo>; | ||
|
|
||
| export const AIAttachmentGrid: Story = { | ||
| render: args => ( | ||
| <div style={{width: 320}}> | ||
| <AttachmentGridDemo {...args} count={5} /> | ||
| </div> | ||
| ) | ||
| }; | ||
|
|
||
| export const Overflow: Story = { | ||
| name: 'Overflow (vertical scroll fade)', | ||
| render: args => ( | ||
| <div style={{width: 320, resize: 'horizontal', overflow: 'hidden'}}> | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe this needs padding |
||
| <AttachmentGridDemo {...args} count={20} /> | ||
| </div> | ||
| ) | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thought this would be a useful story, can remove. |
||
| render: args => ( | ||
| <UserMessage {...args} styles={style({width: 518})}> | ||
| <div className={style({display: 'flex', flexDirection: 'column', gap: 8, width: 'full'})}> | ||
| <AttachmentGrid aria-label="Uploaded files" styles={style({width: 'full'})}> | ||
| {Array.from({length: 20}, (_, i) => ( | ||
| <AttachmentGridItem key={i} aria-label={`file-${i + 1}.pdf`}> | ||
| <AttachmentPreview | ||
| mimeType="application/pdf" | ||
| slot="thumbnail" | ||
| src={new URL('../../s2/stories/assets/placeholder.png', import.meta.url).toString()} | ||
| /> | ||
| </AttachmentGridItem> | ||
| ))} | ||
| </AttachmentGrid> | ||
| </div> | ||
| </UserMessage> | ||
| ) | ||
| }; | ||
|
|
||
| export const WithCard: Story = { | ||
| render: args => ( | ||
| <div style={{width: 300}}> | ||
|
|
||
There was a problem hiding this comment.
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?