diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 13e323b2370..526e2312ee2 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -42,8 +42,10 @@ import {getMimeTypeLabel, matchMimeType} from './PromptField'; import {Image, ImageContext, ImageProps} from '@react-spectrum/s2/Image'; import {ImageCoordinator} from '@react-spectrum/s2/ImageCoordinator'; import ImageIcon from '@react-spectrum/s2/icons/Image'; +// @ts-ignore import intlMessages from '../intl/*.json'; import {keyframes, scrollFade} from './style/style-macro' with {type: 'macro'}; +import {LightboxTriggerContext} from './Lightbox'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import Play from '@react-spectrum/s2/icons/Play'; import {pressScale} from '@react-spectrum/s2/pressScale'; @@ -344,7 +346,7 @@ export interface AttachmentListProps | 'escapeKeyBehavior' | 'onSelectionChange' | 'shouldSelectOnPressUp' - | 'onAction' + // | 'onAction' | 'render' | 'style' | 'className' @@ -496,6 +498,8 @@ export const AttachmentList = (forwardRef as forwardRefType)(function Attachment let domRef = useDOMRef(ref); let scrollRef = useRef(null); let {direction} = useLocale(); + let ctx = useContext(LightboxTriggerContext); + let scroll = (dir: 1 | -1) => { let el = scrollRef.current; if (!el) { @@ -519,6 +523,7 @@ export const AttachmentList = (forwardRef as forwardRefType)(function Attachment return ( { + isInfinite?: boolean; +} + +export interface LightboxPickerProps extends Omit, 'className'> {} +export interface LightboxPickerItemProps extends Omit { + children: ReactNode; +} +export interface LightboxItemsProps extends Omit, 'className'> { + children: ReactNode[]; +} +export interface LightboxItemProps extends Omit {} + +export interface LightboxTrigger { + children: ReactNode; +} + +const carouselStyles = style({ + display: 'flex', + flexDirection: { + orientation: { + horizontal: 'column', + vertical: 'row' + } + }, + gap: 16, + minWidth: 0, + minHeight: 0, + width: 'full', + height: 'full' +}); + +const pickerStyles = style({ + display: 'flex', + flexDirection: { + orientation: { + horizontal: 'row', + vertical: 'column' + } + }, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + order: 1, + gap: 4 +}); + +const pickerItemStyles = style({ + ...focusRing(), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 'lg', + cursor: 'default', + boxSizing: 'border-box', + borderWidth: 2, + borderStyle: 'solid', + borderColor: { + default: 'transparent', + isSelected: { + default: 'gray-800', + isInModal: 'gray-100' + } + } +}); + +const itemsStyles = style({ + minWidth: 0, + minHeight: 0, + borderRadius: 'lg', + width: 'full', + height: 'full', + display: 'flex', + overflowX: 'auto', + overflowY: 'hidden', + scrollbarWidth: 'none', + scrollSnapType: 'x mandatory', + scrollBehavior: { + default: 'smooth', + '@media (prefers-reduced-motion: reduce)': 'auto' + } +}); + +const itemStyles = style({ + ...focusRing(), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 0, + minHeight: 0, + width: 'full', + height: 'full', + flexShrink: 0, + scrollSnapAlign: 'start', + scrollSnapStop: 'always', + paddingX: 16, + boxSizing: 'border-box' +}); + +interface LightboxCarouselContextProps { + isInfinite?: boolean; +} + +let LightBoxCarouselContext = createContext({}); + +export function LightboxCarousel(props: LightboxCarouselProps) { + let ctx = useContext(LightboxTriggerContext); + + // Only render Lightbox Carousel inside a modal if there is a trigger + if (ctx) { + return ( + + style({ + position: 'fixed', + inset: 0, + zIndex: 1000, + backgroundColor: 'black/90', + opacity: { + default: 1, + isEntering: 0, + isExiting: 0 + }, + transition: 'opacity', + transitionDuration: { + default: 250, + isExiting: 130 + } + })(renderProps) + }> + + + {({close}) => ( + <> + + + carouselStyles(renderProps)} + /> + + + )} + + + + ); + } + + return ( + + carouselStyles(renderProps)} /> + + ); +} + +export function LightboxPicker(props: LightboxPickerProps) { + let ctx = useContext(LightboxTriggerContext); + let state = useContext(TabListStateContext); + let picker = pickerStyles(renderProps)} />; + + // Wait until tab state has initialized its focus target before running autofocus. + return state?.selectionManager.focusedKey != null ? ( + {picker} + ) : ( + picker + ); +} + +const thumbnailStyles = style({ + width: 64, + height: 64, + objectFit: 'cover', + borderRadius: 'lg', + opacity: 0.8 +}); + +export function LightboxPickerItem(props: LightboxPickerItemProps) { + let isInModal = useContext(LightboxTriggerContext); + + return ( + pickerItemStyles({...renderProps, isInModal})}> + + {props.children} + + + ); +} + +export function LightboxItems(props: LightboxItemsProps) { + let state = useContext(TabListStateContext)!; + let {isInfinite} = useContext(LightBoxCarouselContext); + + let selectedKey = state?.selectedKey; + let selectedItem = selectedKey != null ? state?.collection.getItem(selectedKey) : null; + + if (selectedItem == null) { + return null; + } + + let wrapper: ReactNode = isInfinite ? ( + + {index => props.children?.at(getWrappedIndex(index, state.collection.size))} + + ) : ( + {index => props.children?.at(index)} + ); + + return ( +
+ {wrapper} +
+ ); +} + +interface FiniteLightboxItemsProps extends Omit, 'children'> { + children: (page: number) => ReactNode; +} + +function FiniteLightboxItems(props: FiniteLightboxItemsProps) { + let {children: renderPage, ...otherProps} = props; + let state = useContext(TabListStateContext)!; + let ref = useRef(null); + + let selectedKey = state?.selectedKey; + let selectedItem = selectedKey != null ? state?.collection.getItem(selectedKey) : null; + + let selectedIndex = selectedItem != null ? selectedItem.index : null; + + useLayoutEffect(() => { + let el = ref.current; + if (!el || selectedIndex == null) { + return; + } + + let currentIndex = Math.round(el.scrollLeft / el.offsetWidth); + if (selectedIndex !== currentIndex) { + // Jump directly because pages between distant selections may not be mounted. + el.scrollTo({ + left: el.offsetWidth * selectedIndex, + behavior: 'instant' + }); + } + }, [selectedIndex, selectedKey]); + + if (selectedIndex == null || state.collection.size === 0) { + return null; + } + + let size = state.collection.size; + let pages = [selectedIndex - 1, selectedIndex, selectedIndex + 1].filter( + page => page >= 0 && page < size + ); + let leadingPages = Math.max(0, selectedIndex - 1); + let trailingPages = Math.max(0, size - selectedIndex - 2); + + let prevItem = () => { + let el = ref.current; + if (el && selectedIndex > 0) { + el.scrollLeft = el.offsetWidth * (selectedIndex - 1); + } + }; + + let nextItem = () => { + let el = ref.current; + if (el && selectedIndex < size - 1) { + el.scrollLeft = el.offsetWidth * (selectedIndex + 1); + } + }; + + let onScroll = () => { + let el = ref.current; + if (!el) { + return; + } + + let currentIndex = Math.max(0, Math.min(size - 1, Math.round(el.scrollLeft / el.offsetWidth))); + if (selectedIndex !== currentIndex) { + let key = [...state.collection].at(currentIndex)?.key; + if (key != null) { + state.setSelectedKey(key); + } + } + }; + + return ( + <> + +
+
+ {pages.map(page => ( +
+ {renderPage(page)} +
+ ))} +
+
+ + + ); +} + +interface CarouselNavButtonProps extends Omit { + side?: 'start' | 'end'; +} + +function CarouselNavButton(props: CarouselNavButtonProps) { + let {side, ...otherProps} = props; + // let {direction} = useLocale(); + // let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); + let Icon = side === 'start' ? ChevronLeft : ChevronRight; + return ( + + ); +} + +interface CarouselState { + centerPage: number; + currentPage: number; +} + +type Action = + | {type: 'SCROLL'; page: number} + | {type: 'SCROLL_END'} + | {type: 'SET_CENTER_PAGE'; page: number}; + +const PAGES = 100; + +function getWrappedIndex(index: number, size: number): number { + return ((index % size) + size) % size; +} + +function reducer(state: CarouselState, action: Action) { + let {centerPage, currentPage} = state; + switch (action.type) { + case 'SET_CENTER_PAGE': + return {centerPage: action.page, currentPage: PAGES}; + case 'SCROLL': + if (action.page === currentPage) { + return state; + } + return {centerPage, currentPage: action.page}; + case 'SCROLL_END': + if (currentPage === PAGES) { + return state; + } + return { + centerPage: centerPage + currentPage - PAGES, + currentPage: PAGES + }; + } +} + +const imageStyles = style({ + display: 'block', + width: 'fit', + height: 'full', + objectFit: 'contain', + borderRadius: 'lg', + backgroundColor: 'transparent' +}); + +interface InfiniteLightboxItemsProps extends Omit, 'children'> { + children: (page: number) => ReactNode; +} + +function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { + let {children: renderPage} = props; + let state = useContext(TabListStateContext)!; + let ref = useRef(null); + + let selectedKey = state?.selectedKey; + let selectedItem = selectedKey != null ? state?.collection.getItem(selectedKey) : null; + + let selectedIndex = selectedItem != null ? selectedItem.index : null; + + let [{centerPage, currentPage}, dispatch] = useReducer(reducer, null, () => ({ + centerPage: selectedIndex ?? 0, + currentPage: PAGES + })); + + // if the selected tab changes (like if you were to click one), update the centered page + if ( + selectedIndex != null && + currentPage === PAGES && + selectedIndex !== getWrappedIndex(centerPage, state.collection.size) + ) { + dispatch({ + type: 'SET_CENTER_PAGE', + page: selectedIndex + }); + } + + // when the center page changes, reset the scroll position + useLayoutEffect(() => { + if (!ref.current) { + return; + } + + ref.current.scrollTo({ + left: ref.current.offsetWidth * PAGES, + behavior: 'instant' + }); + }, [centerPage]); + + if (selectedIndex == null || state.collection.size === 0) { + return null; + } + + let visiblePage = centerPage + currentPage - PAGES; + let pages = [visiblePage - 1, visiblePage, visiblePage + 1]; + + let prevItem = () => { + if (!ref.current) { + return; + } + + ref.current!.scrollLeft = ref.current!.offsetWidth * (PAGES - 1); + }; + + let nextItem = () => { + if (!ref.current) { + return; + } + + ref.current!.scrollLeft = ref.current!.offsetWidth * (PAGES + 1); + }; + + // update selected tab while scrolling + let onScroll = () => { + let el = ref.current; + let index = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; // this is not the collection index, this is the pages index + let collectionIndex = centerPage + index - PAGES; // this is the actual collection index + if (currentPage !== index) { + // actually update the selected key (so that the tabs actually reflect what is in the viewport) + let key = [...state.collection].at( + getWrappedIndex(collectionIndex, state.collection.size) + )?.key; + if (key != null) { + state.setSelectedKey(key); + } + // keeps track of how much we have scrolled from the center page + dispatch({ + type: 'SCROLL', + page: index + }); + } + }; + + let onScrollEnd = () => { + // update the centered page once scrolling is done + dispatch({type: 'SCROLL_END'}); + }; + + return ( + <> + +
+ {/* Placeholder to hold space in the scroll width for more pages. */} +
+ {pages.map(page => ( +
+ {renderPage(page)} +
+ ))} +
+
+ + + ); +} + +export function LightboxItem(props: LightboxItemProps) { + return ( + itemStyles(renderProps)}> + {renderProps => ( + + {typeof props.children === 'function' ? props.children(renderProps) : props.children} + + )} + + ); +} + +export interface LightboxTriggerContextProps { + onAction?: (key: Key) => void; + isOpen?: boolean; + setIsOpen?: (boolean) => void; + activeKey?: Key | undefined; + setActiveKey?: (key: Key) => void; +} + +export const LightboxTriggerContext = createContext(null); + +export function LightboxTrigger(props: LightboxTrigger) { + let [isOpen, setIsOpen] = useState(false); + let [activeKey, setActiveKey] = useState(undefined); + + let onAction = (key: Key) => { + setActiveKey(key); + setIsOpen(true); + }; + + return ( + +
{props.children}
+
+ ); +} diff --git a/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx b/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx new file mode 100644 index 00000000000..af327a29f69 --- /dev/null +++ b/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx @@ -0,0 +1,175 @@ +/* + * 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 {Attachment, AttachmentList, AttachmentPreview} from '../src/AttachmentList'; +import {Image} from '@react-spectrum/s2/Image'; +import { + LightboxCarousel, + LightboxItem, + LightboxItems, + LightboxPicker, + LightboxPickerItem, + LightboxTrigger +} from '../src/Lightbox'; +import type {Meta, StoryObj} from '@storybook/react'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const meta: Meta = { + title: 'AI/Lightbox', + component: LightboxCarousel, + parameters: { + layout: 'centered' + } +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + parameters: { + layout: 'padded' + }, + render: args => ( +
+ + + + + + + + + + + + + + + Amsterdam canal + + + Land's End coastal landscape + + + Trust television series promotion + + + +
+ ), + args: { + defaultSelectedKey: 'amsterdam' + } +}; + +function AttachmentDialogExample() { + return ( + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + Amsterdam canal + + + Land's End coastal landscape + + + Trust television series promotion + + + +
+ ); +} + +export const AttachmentDialog: Story = { + render: () => +};