From 18deb462c91c8532cc368c9f487781e39ec4aa7c Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:21:12 -0700 Subject: [PATCH 1/9] feat: add lightbox component --- packages/@react-spectrum/ai/src/Lightbox.tsx | 142 ++++++++++++++++++ .../ai/stories/Lightbox.stories.tsx | 78 ++++++++++ 2 files changed, 220 insertions(+) create mode 100644 packages/@react-spectrum/ai/src/Lightbox.tsx create mode 100644 packages/@react-spectrum/ai/stories/Lightbox.stories.tsx diff --git a/packages/@react-spectrum/ai/src/Lightbox.tsx b/packages/@react-spectrum/ai/src/Lightbox.tsx new file mode 100644 index 00000000000..2726a6ccc72 --- /dev/null +++ b/packages/@react-spectrum/ai/src/Lightbox.tsx @@ -0,0 +1,142 @@ +/* + * 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 {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import { + Tab as RACTab, + TabList as RACTabList, + TabPanel as RACTabPanel, + TabPanels as RACTabPanels, + Tabs as RACTabs, + TabListProps, + TabPanelProps, + TabPanelsProps, + TabProps, + TabsProps, + TabListStateContext +} from 'react-aria-components/Tabs'; +import {Button} from 'react-aria-components/Button'; +import {useContext, useState} from 'react'; + +export type LightboxCarouselProps = Omit; +export type LightboxPickerProps = Omit, 'className'>; +export type LightboxPickerItemProps = Omit; +export type LightboxItemsProps = Omit, 'className'>; +export type LightboxItemProps = Omit; + +const carouselStyles = style({ + display: 'flex', + flexDirection: { + orientation: { + horizontal: 'column', + vertical: 'row' + } + }, + gap: 16, + minWidth: 0, + width: 'full' +}); + +const pickerStyles = style({ + display: 'flex', + flexDirection: { + orientation: { + horizontal: 'row', + vertical: 'column' + } + }, + alignItems: 'center', + justifyContent: 'center', + gap: 4 +}); + +const pickerItemStyles = style({ + ...focusRing(), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 32, + minHeight: 32, + borderRadius: 'full', + cursor: 'default' +}); + +const pickerDotStyles = style({ + size: 8, + flexShrink: 0, + borderRadius: 'full', + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'gray-600', + backgroundColor: { + default: 'gray-300', + isSelected: 'gray-900' + } +}); + +const itemsStyles = style({ + minWidth: 0, + width: 'full' +}); + +const itemStyles = style({ + ...focusRing(), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 0, + width: 'full' +}); + +export function LightboxCarousel(props: LightboxCarouselProps) { + // let [item, setItem] = useState(); + // let prevItem = () => ( + // setItem() + // ); + + // let state = useContext(TabListStateContext)!; + // console.log(state); + return carouselStyles(renderProps)} />; +} + +export function LightboxPicker(props: LightboxPickerProps) { + let state = useContext(TabListStateContext)!; + console.log(state); + return pickerStyles(renderProps)} />; +} + +export function LightboxPickerItem(props: LightboxPickerItemProps) { + return ( + pickerItemStyles(renderProps)}> + {renderProps => + ); +} + +export function LightboxItems(props: LightboxItemsProps) { + let state = useContext(TabListStateContext)!; + // console.log(state); + return ( +
+ + + +
+ ); + +} + +export function LightboxItem(props: LightboxItemProps) { + return ( + itemStyles(renderProps)} /> + ); +} 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..e79efe9fcc5 --- /dev/null +++ b/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx @@ -0,0 +1,78 @@ +/* + * 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 {LightboxCarousel, LightboxItem, LightboxPicker, LightboxPickerItem, LightboxItems} 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; + +const imageStyles = style({ + display: 'block', + width: 'full', + aspectRatio: '4/3', + objectFit: 'contain' +}); + +export const Basic: Story = { + render: args => ( +
+ + + + + + + + + Amsterdam canal + + + Land's End coastal landscape + + + Trust television series promotion + + + +
+ ), + args: { + defaultSelectedKey: 'amsterdam' + } +}; From 79e99f028ee6990b0bf6f106400a62a4b455e6df Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:51:32 -0700 Subject: [PATCH 2/9] finite scrolling --- packages/@react-spectrum/ai/src/Lightbox.tsx | 259 +++++++++++++++++-- 1 file changed, 239 insertions(+), 20 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Lightbox.tsx b/packages/@react-spectrum/ai/src/Lightbox.tsx index 2726a6ccc72..9840965d109 100644 --- a/packages/@react-spectrum/ai/src/Lightbox.tsx +++ b/packages/@react-spectrum/ai/src/Lightbox.tsx @@ -25,12 +25,17 @@ import { TabListStateContext } from 'react-aria-components/Tabs'; import {Button} from 'react-aria-components/Button'; -import {useContext, useState} from 'react'; +import {createContext, useContext, useLayoutEffect, useState, useRef, useReducer, ReactNode, RefObject} from 'react'; + +export interface LightboxCarouselProps extends Omit { + isInfinite?: boolean; +}; -export type LightboxCarouselProps = Omit; export type LightboxPickerProps = Omit, 'className'>; export type LightboxPickerItemProps = Omit; -export type LightboxItemsProps = Omit, 'className'>; +export interface LightboxItemsProps extends Omit, 'className'> { + children: ReactNode +} export type LightboxItemProps = Omit; const carouselStyles = style({ @@ -85,7 +90,12 @@ const pickerDotStyles = style({ const itemsStyles = style({ minWidth: 0, - width: 'full' + width: 'full', + display: 'flex', + overflow: 'auto', + scrollbarWidth: 'none', + scrollSnapType: 'x mandatory', + scrollBehavior: 'smooth' }); const itemStyles = style({ @@ -94,23 +104,27 @@ const itemStyles = style({ alignItems: 'center', justifyContent: 'center', minWidth: 0, - width: 'full' + width: 'full', + flexShrink: 0, + scrollSnapAlign: 'start', + scrollSnapStop: 'always' }); +interface LightboxCarouselContextProps { + isInfinite?: boolean; +} + +let LightBoxCarouselContext = createContext({}); + export function LightboxCarousel(props: LightboxCarouselProps) { - // let [item, setItem] = useState(); - // let prevItem = () => ( - // setItem() - // ); - - // let state = useContext(TabListStateContext)!; - // console.log(state); - return carouselStyles(renderProps)} />; + return ( + + carouselStyles(renderProps)} /> + + ) } export function LightboxPicker(props: LightboxPickerProps) { - let state = useContext(TabListStateContext)!; - console.log(state); return pickerStyles(renderProps)} />; } @@ -124,19 +138,224 @@ export function LightboxPickerItem(props: LightboxPickerItemProps) { export function LightboxItems(props: LightboxItemsProps) { let state = useContext(TabListStateContext)!; - // console.log(state); + // let ref = useRef(null); + let {isInfinite} = useContext(LightBoxCarouselContext); + // let isProgrammaticScroll = useRef(false); + + // do we want to disable the prev/next button if you are at the start/end? + + // let selectedKey = state?.selectedKey; + // let selectedItem = selectedKey != null + // ? state?.collection.getItem(selectedKey) + // : null; + + // let selectedIndex = selectedItem != null ? selectedItem.index : null + + + let prevItem = () => { + let key = state.selectedKey; + let isFirstKey = state.collection.getFirstKey() === key + if (isInfinite) { + if (isFirstKey) { + state.setSelectedKey(state.collection.getLastKey()); + // Check case where there is only one item... + return; + } + } + + let prevKey = key ? state.collection.getKeyBefore(key) : null; + if (isFirstKey) { + prevKey = state.collection.getFirstKey(); + } + state.setSelectedKey(prevKey); + }; + + let nextItem = () => { + let key = state.selectedKey; + let isLastKey = state.collection.getLastKey() === key; + if (isInfinite) { + if (isLastKey) { + state.setSelectedKey(state.collection.getFirstKey()); + // Check case where there is only one item... + return; + } + } + + let nextKey = key ? state.collection.getKeyAfter(key) : null; + if (isLastKey) { + nextKey = key; + } + state.setSelectedKey(nextKey); + }; + + let wrapper: ReactNode = isInfinite ? ( + + {props.children} + + ) : ( + + {props.children} + + ); + return (
- - - + + {wrapper} +
); +} +interface FiniteLightboxItemsProps extends LightboxItemsProps { + viewportRef?: RefObject +} + +function FiniteLightboxItems(props: FiniteLightboxItemsProps) { + let state = useContext(TabListStateContext)!; + let ref = useRef(null); + let isProgrammaticScroll = useRef(false); + + // do we want to disable the prev/next button if you are at the start/end? + 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; + let currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; + if (selectedIndex != currentIndex ) { + isProgrammaticScroll.current = true; + ref.current!.scrollLeft = ref.current!.offsetWidth * (selectedIndex ?? 0); + } + }, [selectedKey]); + + + let onScroll = () => { + let el = ref.current; + let currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; + if (selectedIndex !== currentIndex && !isProgrammaticScroll.current) { + let key = [...state.collection].at(currentIndex)?.key; + if (key) { + state.setSelectedKey(key) + } + } + } + + let onScrollEnd = () => { + isProgrammaticScroll.current = false; + } + + return ( +
+ {props.children} +
+ ) +} + +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 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 + } + } +} + + +interface InfiniteLightboxItemsProps extends LightboxItemsProps { + viewportRef?: RefObject +} + +function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { + let state = useContext(TabListStateContext)!; + let ref = useRef(null); + let {isInfinite} = useContext(LightBoxCarouselContext); + let isProgrammaticScroll = useRef(false); + + // do we want to disable the prev/next button if you are at the start/end? + + let selectedKey = state?.selectedKey; + let selectedItem = selectedKey != null + ? state?.collection.getItem(selectedKey) + : null; + + let selectedIndex = selectedItem != null ? selectedItem.index : null + + // FOR INFINITE CAROUSEL // + let [{centerPage, currentPage}, dispatch] = useReducer(reducer, null, () => ({centerPage: selectedIndex ?? 0, currentPage: PAGES})) + + if (isInfinite && selectedIndex != null && currentPage === PAGES && selectedIndex !== centerPage) { + dispatch({ + type: 'SET_CENTER_PAGE', + page: selectedIndex + }) + } + // ------------------------------------ // + + useLayoutEffect(() => { + let el = ref.current; + let currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; + if (selectedIndex != currentIndex ) { + isProgrammaticScroll.current = true; + ref.current!.scrollLeft = ref.current!.offsetWidth * (selectedIndex ?? 0); + } + }, [selectedKey]); + + + let onScroll = () => { + let el = ref.current; + let currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; + if (selectedIndex !== currentIndex && !isProgrammaticScroll.current) { + let key = [...state.collection].at(currentIndex)?.key; + if (key) { + state.setSelectedKey(key) + } + } + + if (isInfinite && currentPage !== currentIndex) { + dispatch({ + type: 'SCROLL', + page: currentIndex + }) + } + } + + return ( +
+ {props.children} +
+ ) } export function LightboxItem(props: LightboxItemProps) { return ( - itemStyles(renderProps)} /> + itemStyles(renderProps)} /> ); } From cd980df6170bf1f01e22c9f926200048cac1c5ca Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:00:31 -0700 Subject: [PATCH 3/9] add infinite scrolling --- packages/@react-spectrum/ai/src/Lightbox.tsx | 219 ++++++++++++------- 1 file changed, 136 insertions(+), 83 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Lightbox.tsx b/packages/@react-spectrum/ai/src/Lightbox.tsx index 9840965d109..f9162093ebd 100644 --- a/packages/@react-spectrum/ai/src/Lightbox.tsx +++ b/packages/@react-spectrum/ai/src/Lightbox.tsx @@ -34,7 +34,7 @@ export interface LightboxCarouselProps extends Omit { export type LightboxPickerProps = Omit, 'className'>; export type LightboxPickerItemProps = Omit; export interface LightboxItemsProps extends Omit, 'className'> { - children: ReactNode + children: ReactNode[] } export type LightboxItemProps = Omit; @@ -92,7 +92,8 @@ const itemsStyles = style({ minWidth: 0, width: 'full', display: 'flex', - overflow: 'auto', + overflowX: 'auto', + overflowY: 'hidden', scrollbarWidth: 'none', scrollSnapType: 'x mandatory', scrollBehavior: 'smooth' @@ -140,57 +141,20 @@ export function LightboxItems(props: LightboxItemsProps) { let state = useContext(TabListStateContext)!; // let ref = useRef(null); let {isInfinite} = useContext(LightBoxCarouselContext); - // let isProgrammaticScroll = useRef(false); - // do we want to disable the prev/next button if you are at the start/end? - - // let selectedKey = state?.selectedKey; - // let selectedItem = selectedKey != null - // ? state?.collection.getItem(selectedKey) - // : null; - - // let selectedIndex = selectedItem != null ? selectedItem.index : null - - - let prevItem = () => { - let key = state.selectedKey; - let isFirstKey = state.collection.getFirstKey() === key - if (isInfinite) { - if (isFirstKey) { - state.setSelectedKey(state.collection.getLastKey()); - // Check case where there is only one item... - return; - } - } - - let prevKey = key ? state.collection.getKeyBefore(key) : null; - if (isFirstKey) { - prevKey = state.collection.getFirstKey(); - } - state.setSelectedKey(prevKey); - }; + let selectedKey = state?.selectedKey; + let selectedItem = selectedKey != null + ? state?.collection.getItem(selectedKey) + : null; - let nextItem = () => { - let key = state.selectedKey; - let isLastKey = state.collection.getLastKey() === key; - if (isInfinite) { - if (isLastKey) { - state.setSelectedKey(state.collection.getFirstKey()); - // Check case where there is only one item... - return; - } - } + if (selectedItem == null) { + return null; +} - let nextKey = key ? state.collection.getKeyAfter(key) : null; - if (isLastKey) { - nextKey = key; - } - state.setSelectedKey(nextKey); - }; let wrapper: ReactNode = isInfinite ? ( - {props.children} + {(index) => props.children?.at(index % state.collection.size)} ) : ( @@ -200,9 +164,7 @@ export function LightboxItems(props: LightboxItemsProps) { return (
- - {wrapper} - + {wrapper}
); } @@ -222,9 +184,11 @@ function FiniteLightboxItems(props: FiniteLightboxItemsProps) { ? state?.collection.getItem(selectedKey) : null; - let selectedIndex = selectedItem != null ? selectedItem.index : null + let selectedIndex = selectedItem != null ? selectedItem.index : null; useLayoutEffect(() => { + // When a user clicks prev/next, that changes the selected tab but not the scroll position + // Check if the scroll position doesn't match the selected tab and adjust accordingly let el = ref.current; let currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; if (selectedIndex != currentIndex ) { @@ -233,10 +197,32 @@ function FiniteLightboxItems(props: FiniteLightboxItemsProps) { } }, [selectedKey]); + let prevItem = () => { + let key = state.selectedKey; + let isFirstKey = state.collection.getFirstKey() === key + + let prevKey = key ? state.collection.getKeyBefore(key) : null; + if (isFirstKey) { + prevKey = state.collection.getFirstKey(); + } + state.setSelectedKey(prevKey); + }; + + let nextItem = () => { + let key = state.selectedKey; + let isLastKey = state.collection.getLastKey() === key; + let nextKey = key ? state.collection.getKeyAfter(key) : null; + if (isLastKey) { + nextKey = key; + } + state.setSelectedKey(nextKey); + }; let onScroll = () => { let el = ref.current; let currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; + + // Update the selected tab when scrolling (but do not update when the scrolling is coming from next/prev buttons) if (selectedIndex !== currentIndex && !isProgrammaticScroll.current) { let key = [...state.collection].at(currentIndex)?.key; if (key) { @@ -250,9 +236,13 @@ function FiniteLightboxItems(props: FiniteLightboxItemsProps) { } return ( -
- {props.children} -
+ <> + +
+ {props.children} +
+ + ) } @@ -288,69 +278,132 @@ function reducer(state: CarouselState, action: Action) { } } - -interface InfiniteLightboxItemsProps extends LightboxItemsProps { +interface InfiniteLightboxItemsProps extends Omit, 'children'> { + children: (page: number) => ReactNode, viewportRef?: RefObject } function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { + let {children: renderPage} = props; let state = useContext(TabListStateContext)!; let ref = useRef(null); - let {isInfinite} = useContext(LightBoxCarouselContext); - let isProgrammaticScroll = useRef(false); + let isProgrammaticScrolling = useRef(false); - // do we want to disable the prev/next button if you are at the start/end? let selectedKey = state?.selectedKey; let selectedItem = selectedKey != null ? state?.collection.getItem(selectedKey) : null; - let selectedIndex = selectedItem != null ? selectedItem.index : null - - // FOR INFINITE CAROUSEL // + let selectedIndex = selectedItem != null ? selectedItem.index : null; + + if (!state || selectedIndex == null || state.collection.size === 0) { + return null; + } + + let [{centerPage, currentPage}, dispatch] = useReducer(reducer, null, () => ({centerPage: selectedIndex ?? 0, currentPage: PAGES})) - if (isInfinite && selectedIndex != null && currentPage === PAGES && selectedIndex !== centerPage) { + // if the selected tab changes (like if you were to click one), update the centered page + if (selectedIndex != null && currentPage === PAGES && selectedIndex !== centerPage) { + // console.log(selectedIndex); dispatch({ type: 'SET_CENTER_PAGE', page: selectedIndex }) } - // ------------------------------------ // + // when the center page changes, reset the scroll position useLayoutEffect(() => { - let el = ref.current; - let currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; - if (selectedIndex != currentIndex ) { - isProgrammaticScroll.current = true; - ref.current!.scrollLeft = ref.current!.offsetWidth * (selectedIndex ?? 0); + if (!ref.current) { + return; } - }, [selectedKey]); + console.log('layout'); + + // ref.current!.scrollLeft = ref.current!.offsetWidth * PAGES; + + ref.current.scrollTo({ + left: ref.current.offsetWidth * PAGES, + behavior: 'instant' + }); + }, [centerPage]); + + 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 currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; - if (selectedIndex !== currentIndex && !isProgrammaticScroll.current) { - let key = [...state.collection].at(currentIndex)?.key; + 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(collectionIndex % state.collection.size)?.key; if (key) { - state.setSelectedKey(key) + state.setSelectedKey(key); } + // keeps track of how much we have scrolled from the center page + dispatch({ + type: 'SCROLL', + page: index + }) } + console.log('onscroll'); + } - if (isInfinite && currentPage !== currentIndex) { - dispatch({ - type: 'SCROLL', - page: currentIndex - }) - } + let onScrollEnd = () => { + console.log('scrollend') + // update the centered page once scrolling is done + dispatch({type: 'SCROLL_END'}); } return ( -
- {props.children} -
+ <> + +
+ {/* Placeholder to hold space in the scroll width for more pages. */} +
+ {/* contain: 'inline-size' makes these extra pages not affect the width of the parent */} +
+ {renderPage(selectedIndex - 1)} +
+ {/* Center (visible) page */} +
+ {renderPage(selectedIndex )} +
+
+ {renderPage(selectedIndex + 1)} +
+
+
+ + ) } From 16528a691eb678a86e7ad693c7c9e54bc472a83f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:32:50 -0700 Subject: [PATCH 4/9] hooke up dialog --- .../@react-spectrum/ai/src/AttachmentList.tsx | 8 +- packages/@react-spectrum/ai/src/Lightbox.tsx | 91 ++++++++++++++++++- .../ai/stories/Lightbox.stories.tsx | 85 ++++++++++++++++- 3 files changed, 178 insertions(+), 6 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 13e323b2370..d6b32d2d692 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -42,6 +42,7 @@ 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 {mergeStyles} from '@react-spectrum/s2/mergeStyles'; @@ -62,6 +63,8 @@ import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {LightboxTriggerContext} from './Lightbox'; + /** Whether an attachment is still uploading, shared by Attachment and AttachmentGridItem. */ export function isAttachmentLoading(uploadProgress?: number): boolean { return uploadProgress != null && uploadProgress < 100; @@ -344,7 +347,7 @@ export interface AttachmentListProps | 'escapeKeyBehavior' | 'onSelectionChange' | 'shouldSelectOnPressUp' - | 'onAction' + // | 'onAction' | 'render' | 'style' | 'className' @@ -496,6 +499,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 +524,7 @@ export const AttachmentList = (forwardRef as forwardRefType)(function Attachment return ( { isInfinite?: boolean; @@ -118,6 +120,63 @@ interface LightboxCarouselContextProps { let LightBoxCarouselContext = createContext({}); export function LightboxCarousel(props: LightboxCarouselProps) { + + let ctx = useContext(LightboxTriggerContext); + + if (ctx) { + return ( + + + + {({close}) => ( + <> + +
+ + carouselStyles(renderProps)} /> + +
+ + )} +
+
+
+ ) + } + + // maybe if there is a lightbox trigger context, then we render it as a modal, but otherwise, we just render the tabs? return ( carouselStyles(renderProps)} /> @@ -412,3 +471,33 @@ export function LightboxItem(props: LightboxItemProps) { itemStyles(renderProps)} /> ); } + +export interface LightboxTriggerContextProps { + onAction?: (key: Key) => void; + isOpen?: boolean; + setIsOpen?: (boolean) => void; +} + +export const LightboxTriggerContext = createContext(null); + +interface LightboxTrigger { + children: ReactNode +}; + +export function LightboxTrigger(props: LightboxTrigger) { + let [isOpen, setIsOpen] = useState(false); + + let onAction = () => { + setIsOpen(true); + } + + // and then i pass onAction to the attachment + // and then set isOpen back to false when it is dismissed? + return ( + +
+ {props.children} +
+
+ ) +} diff --git a/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx b/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx index e79efe9fcc5..ed82052f6c0 100644 --- a/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx @@ -10,9 +10,25 @@ * governing permissions and limitations under the License. */ -import {LightboxCarousel, LightboxItem, LightboxPicker, LightboxPickerItem, LightboxItems} from '../src/Lightbox'; +import {Attachment, AttachmentList, AttachmentPreview} from '../src/AttachmentList'; +import {Button as AriaButton} from 'react-aria-components/Button'; +import {Button} from '@react-spectrum/s2/Button'; +import {Content} from '@react-spectrum/s2/Content'; +import {Dialog} from 'react-aria-components/Dialog'; +import {Heading} from 'react-aria-components/Heading'; +import { + LightboxCarousel, + LightboxItem, + LightboxItems, + LightboxPicker, + LightboxPickerItem, + LightboxTrigger +} from '../src/Lightbox'; import type {Meta, StoryObj} from '@storybook/react'; +import {Modal, ModalOverlay} from 'react-aria-components/Modal'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Text} from '@react-spectrum/s2/Text'; +import {useState} from 'react'; const meta: Meta = { title: 'AI/Lightbox', @@ -26,11 +42,12 @@ export default meta; type Story = StoryObj; +// TODO: how to style the images so they all have the same aspect ratio, should probably be built in. Look at how Cards handles it? const imageStyles = style({ display: 'block', - width: 'full', - aspectRatio: '4/3', - objectFit: 'contain' + width: 'full' + // aspectRatio: '4/3', + // objectFit: 'contain' }); export const Basic: Story = { @@ -76,3 +93,63 @@ export const Basic: Story = { defaultSelectedKey: 'amsterdam' } }; + +function AttachmentDialogExample() { + + return ( + +
+ + + + + + + + + +
+ + + + + + + + + Amsterdam canal + + + Land's End coastal landscape + + + Trust television series promotion + + + +
+ ); +} + +export const AttachmentDialog: Story = { + render: () => +}; From 60631edadcbc642f56c08f0a522ed1408660fe49 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:03:02 -0700 Subject: [PATCH 5/9] open dialog when clicking on attachment --- packages/@react-spectrum/ai/src/Lightbox.tsx | 48 +++++++++---------- .../ai/stories/Lightbox.stories.tsx | 10 ++-- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Lightbox.tsx b/packages/@react-spectrum/ai/src/Lightbox.tsx index a9c4dd0b191..89151878c53 100644 --- a/packages/@react-spectrum/ai/src/Lightbox.tsx +++ b/packages/@react-spectrum/ai/src/Lightbox.tsx @@ -28,6 +28,9 @@ import {Button} from 'react-aria-components/Button'; import {createContext, useContext, useLayoutEffect, useState, useRef, useReducer, ReactNode, RefObject} from 'react'; import {Key} from '@react-types/shared'; import { ModalOverlay, Modal, Dialog} from 'react-aria-components/Modal'; +import ChevronLeft from '@react-spectrum/s2/icons/ChevronLeft'; +import ChevronRight from '@react-spectrum/s2/icons/ChevronRight'; +import {CloseButton} from '@react-spectrum/s2/CloseButton'; export interface LightboxCarouselProps extends Omit { isInfinite?: boolean; @@ -113,6 +116,12 @@ const itemStyles = style({ scrollSnapStop: 'always' }); +const buttonStyles = style({ + size: 32, + borderRadius: 'full', + borderStyle: 'none', +}) + interface LightboxCarouselContextProps { isInfinite?: boolean; } @@ -148,24 +157,10 @@ export function LightboxCarousel(props: LightboxCarouselProps) { })}> {({close}) => ( <> - +
- carouselStyles(renderProps)} /> + carouselStyles(renderProps)} />
@@ -222,7 +217,7 @@ export function LightboxItems(props: LightboxItemsProps) { ); return ( -
+
{wrapper}
); @@ -296,11 +291,11 @@ function FiniteLightboxItems(props: FiniteLightboxItemsProps) { return ( <> - +
{props.children}
- + ) } @@ -346,7 +341,6 @@ function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { let {children: renderPage} = props; let state = useContext(TabListStateContext)!; let ref = useRef(null); - let isProgrammaticScrolling = useRef(false); let selectedKey = state?.selectedKey; @@ -432,7 +426,7 @@ function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { return ( <> - +
{/* Placeholder to hold space in the scroll width for more pages. */}
(props: InfiniteLightboxItemsProps) { }} />
- + ) } @@ -476,6 +470,8 @@ export interface LightboxTriggerContextProps { onAction?: (key: Key) => void; isOpen?: boolean; setIsOpen?: (boolean) => void; + activeKey?: Key | undefined; + setActiveKey?: (key: Key) => void; } export const LightboxTriggerContext = createContext(null); @@ -486,15 +482,15 @@ interface LightboxTrigger { export function LightboxTrigger(props: LightboxTrigger) { let [isOpen, setIsOpen] = useState(false); + let [activeKey, setActiveKey] = useState(undefined); - let onAction = () => { + let onAction = (key: Key) => { + setActiveKey(key); setIsOpen(true); } - // and then i pass onAction to the attachment - // and then set isOpen back to false when it is dismissed? return ( - +
{props.children}
diff --git a/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx b/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx index ed82052f6c0..590890eae1b 100644 --- a/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx @@ -100,14 +100,14 @@ function AttachmentDialogExample() {
- + - +
- + @@ -137,13 +137,13 @@ function AttachmentDialogExample() { className={imageStyles} /> - + {/* Trust television series promotion - + */}
From 0cb14ca35701bf9dfb969b2e7a69c49930a61894 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:01:44 -0700 Subject: [PATCH 6/9] add more styling --- packages/@react-spectrum/ai/src/Lightbox.tsx | 185 +++++++++++++------ 1 file changed, 124 insertions(+), 61 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Lightbox.tsx b/packages/@react-spectrum/ai/src/Lightbox.tsx index 89151878c53..fdf54d4a9b3 100644 --- a/packages/@react-spectrum/ai/src/Lightbox.tsx +++ b/packages/@react-spectrum/ai/src/Lightbox.tsx @@ -11,6 +11,7 @@ */ import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {ButtonProps} from 'react-aria-components/Button' import { Tab as RACTab, TabList as RACTabList, @@ -24,20 +25,24 @@ import { TabsProps, TabListStateContext, } from 'react-aria-components/Tabs'; -import {Button} from 'react-aria-components/Button'; +// import {Button} from 'react-aria-components/Button'; +import {Button} from '@react-spectrum/s2/Button'; +import {FocusScope} from 'react-aria/FocusScope'; import {createContext, useContext, useLayoutEffect, useState, useRef, useReducer, ReactNode, RefObject} from 'react'; import {Key} from '@react-types/shared'; import { ModalOverlay, Modal, Dialog} from 'react-aria-components/Modal'; import ChevronLeft from '@react-spectrum/s2/icons/ChevronLeft'; import ChevronRight from '@react-spectrum/s2/icons/ChevronRight'; import {CloseButton} from '@react-spectrum/s2/CloseButton'; - +import {ImageContext} from '@react-spectrum/s2/Image'; export interface LightboxCarouselProps extends Omit { isInfinite?: boolean; }; export type LightboxPickerProps = Omit, 'className'>; -export type LightboxPickerItemProps = Omit; +export interface LightboxPickerItemProps extends Omit{ + children: ReactNode; +} export interface LightboxItemsProps extends Omit, 'className'> { children: ReactNode[] } @@ -53,6 +58,7 @@ const carouselStyles = style({ }, gap: 16, minWidth: 0, + minHeight: 0, width: 'full' }); @@ -66,6 +72,7 @@ const pickerStyles = style({ }, alignItems: 'center', justifyContent: 'center', + flexShrink: 0, gap: 4 }); @@ -74,28 +81,23 @@ const pickerItemStyles = style({ display: 'flex', alignItems: 'center', justifyContent: 'center', - minWidth: 32, - minHeight: 32, - borderRadius: 'full', - cursor: 'default' -}); - -const pickerDotStyles = style({ - size: 8, - flexShrink: 0, - borderRadius: 'full', - borderWidth: 1, + borderRadius: 'lg', + cursor: 'default', + boxSizing: 'border-box', + borderWidth: 2, borderStyle: 'solid', - borderColor: 'gray-600', - backgroundColor: { - default: 'gray-300', - isSelected: 'gray-900' + borderColor: { + default: 'transparent', + isSelected: 'gray-800' } }); const itemsStyles = style({ minWidth: 0, + minHeight: 0, + borderRadius: 'lg', width: 'full', + height: 'full', display: 'flex', overflowX: 'auto', overflowY: 'hidden', @@ -110,7 +112,9 @@ const itemStyles = style({ alignItems: 'center', justifyContent: 'center', minWidth: 0, + minHeight: 0, width: 'full', + height: 'full', flexShrink: 0, scrollSnapAlign: 'start', scrollSnapStop: 'always' @@ -158,11 +162,9 @@ export function LightboxCarousel(props: LightboxCarouselProps) { {({close}) => ( <> -
carouselStyles(renderProps)} /> -
)} @@ -180,14 +182,34 @@ export function LightboxCarousel(props: LightboxCarouselProps) { } export function LightboxPicker(props: LightboxPickerProps) { - return pickerStyles(renderProps)} />; + 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: .8 +}) + export function LightboxPickerItem(props: LightboxPickerItemProps) { return ( - pickerItemStyles(renderProps)}> - {renderProps => + pickerItemStyles(renderProps)}> + + {props.children} + + ); } @@ -217,7 +239,13 @@ export function LightboxItems(props: LightboxItemsProps) { ); return ( -
+
{wrapper}
); @@ -291,15 +319,43 @@ function FiniteLightboxItems(props: FiniteLightboxItemsProps) { return ( <> - + + {/* */}
{props.children}
- + + {/* */} ) } +interface CarouselNavButtonProps extends ButtonProps { + 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; @@ -332,6 +388,16 @@ function reducer(state: CarouselState, action: Action) { } } +const imageStyles = style({ + display: 'block', + width: 'auto', + height: 'auto', + maxWidth: 'full', + maxHeight: 'full', + objectFit: 'contain', + borderRadius: 'lg' +}); + interface InfiniteLightboxItemsProps extends Omit, 'children'> { children: (page: number) => ReactNode, viewportRef?: RefObject @@ -370,11 +436,7 @@ function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { useLayoutEffect(() => { if (!ref.current) { return; - } - - console.log('layout'); - - // ref.current!.scrollLeft = ref.current!.offsetWidth * PAGES; + } ref.current.scrollTo({ left: ref.current.offsetWidth * PAGES, @@ -415,7 +477,6 @@ function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { page: index }) } - console.log('onscroll'); } let onScrollEnd = () => { @@ -426,36 +487,38 @@ function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { return ( <> - +
- {/* Placeholder to hold space in the scroll width for more pages. */} -
- {/* contain: 'inline-size' makes these extra pages not affect the width of the parent */} -
- {renderPage(selectedIndex - 1)} -
- {/* Center (visible) page */} -
- {renderPage(selectedIndex )} -
-
- {renderPage(selectedIndex + 1)} -
-
+ + {/* Placeholder to hold space in the scroll width for more pages. */} +
+ {/* contain: 'inline-size' makes these extra pages not affect the width of the parent */} +
+ {renderPage(selectedIndex - 1)} +
+ {/* Center (visible) page */} +
+ {renderPage(selectedIndex )} +
+
+ {renderPage(selectedIndex + 1)} +
+
+
- + ) } From c42b493d22d3094e1716f6314b515a4d82e89b25 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:09:00 -0700 Subject: [PATCH 7/9] update stories --- .../ai/stories/Lightbox.stories.tsx | 104 +++++++++++------- 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx b/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx index 590890eae1b..af327a29f69 100644 --- a/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Lightbox.stories.tsx @@ -11,11 +11,7 @@ */ import {Attachment, AttachmentList, AttachmentPreview} from '../src/AttachmentList'; -import {Button as AriaButton} from 'react-aria-components/Button'; -import {Button} from '@react-spectrum/s2/Button'; -import {Content} from '@react-spectrum/s2/Content'; -import {Dialog} from 'react-aria-components/Dialog'; -import {Heading} from 'react-aria-components/Heading'; +import {Image} from '@react-spectrum/s2/Image'; import { LightboxCarousel, LightboxItem, @@ -25,10 +21,7 @@ import { LightboxTrigger } from '../src/Lightbox'; import type {Meta, StoryObj} from '@storybook/react'; -import {Modal, ModalOverlay} from 'react-aria-components/Modal'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Text} from '@react-spectrum/s2/Text'; -import {useState} from 'react'; const meta: Meta = { title: 'AI/Lightbox', @@ -42,47 +35,62 @@ export default meta; type Story = StoryObj; -// TODO: how to style the images so they all have the same aspect ratio, should probably be built in. Look at how Cards handles it? -const imageStyles = style({ - display: 'block', - width: 'full' - // aspectRatio: '4/3', - // objectFit: 'contain' -}); - export const Basic: Story = { + parameters: { + layout: 'padded' + }, render: args => (
+ className={style({ + width: 600, + height: 400, + maxWidth: 'full', + marginX: 'auto', + maxHeight: 'calc(100dvh - 32px)', + minWidth: 0, + minHeight: 0 + })}> - - - + + + + + + + + + - Amsterdam canal - Land's End coastal landscape - Trust television series promotion @@ -95,7 +103,6 @@ export const Basic: Story = { }; function AttachmentDialogExample() { - return (
@@ -108,42 +115,55 @@ function AttachmentDialogExample() { /> - + src="https://www.w3.org/WAI/content-images/wai-aria-practices/patterns/carousel/examples/images/lands-endslide__800x600.jpg" + /> + + + -
- - - + + + + + + - Amsterdam canal - Land's End coastal landscape - {/* - + Trust television series promotion - */} +
From dd6648afaeab6484f6d83dad27fb8159e0ac1f93 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:32:35 -0700 Subject: [PATCH 8/9] fix lint, fix responsiveness and resizing in carousel --- .../@react-spectrum/ai/src/AttachmentList.tsx | 3 +- packages/@react-spectrum/ai/src/Lightbox.tsx | 413 ++++++++++-------- 2 files changed, 222 insertions(+), 194 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index d6b32d2d692..526e2312ee2 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -45,6 +45,7 @@ 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'; @@ -63,8 +64,6 @@ import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; -import {LightboxTriggerContext} from './Lightbox'; - /** Whether an attachment is still uploading, shared by Attachment and AttachmentGridItem. */ export function isAttachmentLoading(uploadProgress?: number): boolean { return uploadProgress != null && uploadProgress < 100; diff --git a/packages/@react-spectrum/ai/src/Lightbox.tsx b/packages/@react-spectrum/ai/src/Lightbox.tsx index fdf54d4a9b3..f55d4a35ced 100644 --- a/packages/@react-spectrum/ai/src/Lightbox.tsx +++ b/packages/@react-spectrum/ai/src/Lightbox.tsx @@ -10,43 +10,46 @@ * governing permissions and limitations under the License. */ +import {Button, ButtonProps} from '@react-spectrum/s2/Button'; +import ChevronLeft from '@react-spectrum/s2/icons/ChevronLeft'; +import ChevronRight from '@react-spectrum/s2/icons/ChevronRight'; +import {CloseButton} from '@react-spectrum/s2/CloseButton'; +import {createContext, ReactNode, useContext, useReducer, useRef, useState} from 'react'; +import {Dialog, Modal, ModalOverlay} from 'react-aria-components/Modal'; import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {ButtonProps} from 'react-aria-components/Button' +import {FocusScope} from 'react-aria/FocusScope'; +import {ImageContext} from '@react-spectrum/s2/Image'; +import {Key} from '@react-types/shared'; import { Tab as RACTab, TabList as RACTabList, TabPanel as RACTabPanel, - TabPanels as RACTabPanels, Tabs as RACTabs, TabListProps, + TabListStateContext, TabPanelProps, TabPanelsProps, TabProps, - TabsProps, - TabListStateContext, + TabsProps } from 'react-aria-components/Tabs'; -// import {Button} from 'react-aria-components/Button'; -import {Button} from '@react-spectrum/s2/Button'; -import {FocusScope} from 'react-aria/FocusScope'; -import {createContext, useContext, useLayoutEffect, useState, useRef, useReducer, ReactNode, RefObject} from 'react'; -import {Key} from '@react-types/shared'; -import { ModalOverlay, Modal, Dialog} from 'react-aria-components/Modal'; -import ChevronLeft from '@react-spectrum/s2/icons/ChevronLeft'; -import ChevronRight from '@react-spectrum/s2/icons/ChevronRight'; -import {CloseButton} from '@react-spectrum/s2/CloseButton'; -import {ImageContext} from '@react-spectrum/s2/Image'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; + export interface LightboxCarouselProps extends Omit { isInfinite?: boolean; -}; +} -export type LightboxPickerProps = Omit, 'className'>; -export interface LightboxPickerItemProps extends Omit{ +export interface LightboxPickerProps extends Omit, 'className'> {} +export interface LightboxPickerItemProps extends Omit { children: ReactNode; } export interface LightboxItemsProps extends Omit, 'className'> { - children: ReactNode[] + children: ReactNode[]; +} +export interface LightboxItemProps extends Omit {} + +export interface LightboxTrigger { + children: ReactNode; } -export type LightboxItemProps = Omit; const carouselStyles = style({ display: 'flex', @@ -59,7 +62,8 @@ const carouselStyles = style({ gap: 16, minWidth: 0, minHeight: 0, - width: 'full' + width: 'full', + height: 'full' }); const pickerStyles = style({ @@ -73,6 +77,7 @@ const pickerStyles = style({ alignItems: 'center', justifyContent: 'center', flexShrink: 0, + order: 1, gap: 4 }); @@ -88,7 +93,10 @@ const pickerItemStyles = style({ borderStyle: 'solid', borderColor: { default: 'transparent', - isSelected: 'gray-800' + isSelected: { + default: 'gray-800', + isInModal: 'gray-100' + } } }); @@ -103,7 +111,10 @@ const itemsStyles = style({ overflowY: 'hidden', scrollbarWidth: 'none', scrollSnapType: 'x mandatory', - scrollBehavior: 'smooth' + scrollBehavior: { + default: 'smooth', + '@media (prefers-reduced-motion: reduce)': 'auto' + } }); const itemStyles = style({ @@ -117,15 +128,11 @@ const itemStyles = style({ height: 'full', flexShrink: 0, scrollSnapAlign: 'start', - scrollSnapStop: 'always' + scrollSnapStop: 'always', + paddingX: 16, + boxSizing: 'border-box' }); -const buttonStyles = style({ - size: 32, - borderRadius: 'full', - borderStyle: 'none', -}) - interface LightboxCarouselContextProps { isInfinite?: boolean; } @@ -133,52 +140,68 @@ interface LightboxCarouselContextProps { 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' - })}> - - - {({close}) => ( - <> + backgroundColor: 'black/90', + opacity: { + default: 1, + isEntering: 0, + isExiting: 0 + }, + transition: 'opacity', + transitionDuration: { + default: 250, + isExiting: 130 + } + })(renderProps) + }> + + + {({close}) => ( + <> - - carouselStyles(renderProps)} /> - - - )} - - - - ) + + carouselStyles(renderProps)} + /> + + + )} + + + + ); } - // maybe if there is a lightbox trigger context, then we render it as a modal, but otherwise, we just render the tabs? return ( carouselStyles(renderProps)} /> - ) + ); } export function LightboxPicker(props: LightboxPickerProps) { @@ -188,72 +211,68 @@ export function LightboxPicker(props: LightboxPickerProps) { // Wait until tab state has initialized its focus target before running autofocus. return state?.selectionManager.focusedKey != null ? ( - - {picker} - - ) : picker; + {picker} + ) : ( + picker + ); } - const thumbnailStyles = style({ width: 64, height: 64, objectFit: 'cover', borderRadius: 'lg', - opacity: .8 -}) + opacity: 0.8 +}); export function LightboxPickerItem(props: LightboxPickerItemProps) { + let isInModal = useContext(LightboxTriggerContext); + return ( - pickerItemStyles(renderProps)}> - - {props.children} - - + pickerItemStyles({...renderProps, isInModal})}> + + {props.children} + + ); } export function LightboxItems(props: LightboxItemsProps) { let state = useContext(TabListStateContext)!; - // let ref = useRef(null); let {isInfinite} = useContext(LightBoxCarouselContext); let selectedKey = state?.selectedKey; - let selectedItem = selectedKey != null - ? state?.collection.getItem(selectedKey) - : null; + let selectedItem = selectedKey != null ? state?.collection.getItem(selectedKey) : null; if (selectedItem == null) { return null; -} - + } let wrapper: ReactNode = isInfinite ? ( - {(index) => props.children?.at(index % state.collection.size)} + {index => props.children?.at(getWrappedIndex(index, state.collection.size))} ) : ( - - {props.children} - + {props.children} ); return ( -
+
{wrapper}
); } -interface FiniteLightboxItemsProps extends LightboxItemsProps { - viewportRef?: RefObject -} +interface FiniteLightboxItemsProps extends LightboxItemsProps {} function FiniteLightboxItems(props: FiniteLightboxItemsProps) { let state = useContext(TabListStateContext)!; @@ -262,26 +281,24 @@ function FiniteLightboxItems(props: FiniteLightboxItemsProps) { // do we want to disable the prev/next button if you are at the start/end? let selectedKey = state?.selectedKey; - let selectedItem = selectedKey != null - ? state?.collection.getItem(selectedKey) - : null; + let selectedItem = selectedKey != null ? state?.collection.getItem(selectedKey) : null; let selectedIndex = selectedItem != null ? selectedItem.index : null; - + useLayoutEffect(() => { // When a user clicks prev/next, that changes the selected tab but not the scroll position // Check if the scroll position doesn't match the selected tab and adjust accordingly let el = ref.current; let currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; - if (selectedIndex != currentIndex ) { + if (selectedIndex !== currentIndex) { isProgrammaticScroll.current = true; ref.current!.scrollLeft = ref.current!.offsetWidth * (selectedIndex ?? 0); } - }, [selectedKey]); + }, [selectedIndex, selectedKey]); - let prevItem = () => { + let prevItem = () => { let key = state.selectedKey; - let isFirstKey = state.collection.getFirstKey() === key + let isFirstKey = state.collection.getFirstKey() === key; let prevKey = key ? state.collection.getKeyBefore(key) : null; if (isFirstKey) { @@ -308,34 +325,37 @@ function FiniteLightboxItems(props: FiniteLightboxItemsProps) { if (selectedIndex !== currentIndex && !isProgrammaticScroll.current) { let key = [...state.collection].at(currentIndex)?.key; if (key) { - state.setSelectedKey(key) + state.setSelectedKey(key); } } - } + }; let onScrollEnd = () => { isProgrammaticScroll.current = false; - } + }; return ( <> - {/* */} -
- {props.children} -
+
+ {props.children} +
- {/* */} - ) + ); } -interface CarouselNavButtonProps extends ButtonProps { - side?: 'start' | 'end' +interface CarouselNavButtonProps extends Omit { + side?: 'start' | 'end'; } function CarouselNavButton(props: CarouselNavButtonProps) { - let {side, ...otherProps} = props; + let {side, ...otherProps} = props; // let {direction} = useLocale(); // let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let Icon = side === 'start' ? ChevronLeft : ChevronRight; @@ -346,11 +366,7 @@ function CarouselNavButton(props: CarouselNavButtonProps) { // aria-label={stringFormatter.format( // side === 'start' ? 'attachmentlist.previousAttachments' : 'attachmentlist.nextAttachments' // )} - // style={pressScale(ref, { - // animationTimeline: '--carousel-scroll', - // animationRange: side === 'start' ? '0px 32px' : 'calc(100% - 32px) 100%' - // } as CSSProperties)}> - > + > ); @@ -362,45 +378,48 @@ interface CarouselState { } type Action = - | { type: 'SCROLL'; page: number } - | { type: 'SCROLL_END' } - | { type: 'SET_CENTER_PAGE'; page: number }; + | {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} + return {centerPage: action.page, currentPage: PAGES}; case 'SCROLL': if (action.page === currentPage) { return state; } - return {centerPage, currentPage: action.page} + return {centerPage, currentPage: action.page}; case 'SCROLL_END': if (currentPage === PAGES) { return state; } return { - centerPage: centerPage + currentPage - PAGES, currentPage: PAGES - } + centerPage: centerPage + currentPage - PAGES, + currentPage: PAGES + }; } } const imageStyles = style({ display: 'block', - width: 'auto', - height: 'auto', - maxWidth: 'full', - maxHeight: 'full', + width: 'fit', + height: 'full', objectFit: 'contain', - borderRadius: 'lg' + borderRadius: 'lg', + backgroundColor: 'transparent' }); interface InfiniteLightboxItemsProps extends Omit, 'children'> { - children: (page: number) => ReactNode, - viewportRef?: RefObject + children: (page: number) => ReactNode; } function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { @@ -408,35 +427,33 @@ function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { 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; - - if (!state || selectedIndex == null || state.collection.size === 0) { - return null; - } + 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})) + 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 !== centerPage) { - // console.log(selectedIndex); + 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, @@ -444,88 +461,106 @@ function InfiniteLightboxItems(props: InfiniteLightboxItemsProps) { }); }, [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 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(collectionIndex % state.collection.size)?.key; - if (key) { + 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 = () => { - console.log('scrollend') // update the centered page once scrolling is done dispatch({type: 'SCROLL_END'}); - } + }; return ( <>
- - {/* Placeholder to hold space in the scroll width for more pages. */} + {/* Placeholder to hold space in the scroll width for more pages. */} +
+ {pages.map(page => (
- {/* contain: 'inline-size' makes these extra pages not affect the width of the parent */} -
- {renderPage(selectedIndex - 1)} + contain: page !== visiblePage ? 'inline-size' : undefined, + scrollSnapAlign: 'start', + scrollSnapStop: 'always' + }}> + {renderPage(page)}
- {/* Center (visible) page */} -
- {renderPage(selectedIndex )} -
-
- {renderPage(selectedIndex + 1)} -
-
- + ))} +
- ) + ); } export function LightboxItem(props: LightboxItemProps) { return ( - itemStyles(renderProps)} /> + itemStyles(renderProps)}> + {renderProps => ( + + {typeof props.children === 'function' ? props.children(renderProps) : props.children} + + )} + ); } @@ -539,24 +574,18 @@ export interface LightboxTriggerContextProps { export const LightboxTriggerContext = createContext(null); -interface LightboxTrigger { - children: ReactNode -}; - 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} -
+
{props.children}
- ) + ); } From 6941cff04d4809196cfb59f8a7b58f2516285f68 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:13:50 -0700 Subject: [PATCH 9/9] use same infinite pattern in fininte carousel --- packages/@react-spectrum/ai/src/Lightbox.tsx | 109 ++++++++++++------- 1 file changed, 70 insertions(+), 39 deletions(-) diff --git a/packages/@react-spectrum/ai/src/Lightbox.tsx b/packages/@react-spectrum/ai/src/Lightbox.tsx index f55d4a35ced..5a312afd092 100644 --- a/packages/@react-spectrum/ai/src/Lightbox.tsx +++ b/packages/@react-spectrum/ai/src/Lightbox.tsx @@ -253,7 +253,7 @@ export function LightboxItems(props: LightboxItemsProps) { {index => props.children?.at(getWrappedIndex(index, state.collection.size))} ) : ( - {props.children} + {index => props.children?.at(index)} ); return ( @@ -272,80 +272,111 @@ export function LightboxItems(props: LightboxItemsProps) { ); } -interface FiniteLightboxItemsProps extends LightboxItemsProps {} +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 isProgrammaticScroll = useRef(false); - // do we want to disable the prev/next button if you are at the start/end? let selectedKey = state?.selectedKey; let selectedItem = selectedKey != null ? state?.collection.getItem(selectedKey) : null; let selectedIndex = selectedItem != null ? selectedItem.index : null; useLayoutEffect(() => { - // When a user clicks prev/next, that changes the selected tab but not the scroll position - // Check if the scroll position doesn't match the selected tab and adjust accordingly let el = ref.current; - let currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; + if (!el || selectedIndex == null) { + return; + } + + let currentIndex = Math.round(el.scrollLeft / el.offsetWidth); if (selectedIndex !== currentIndex) { - isProgrammaticScroll.current = true; - ref.current!.scrollLeft = ref.current!.offsetWidth * (selectedIndex ?? 0); + // Jump directly because pages between distant selections may not be mounted. + el.scrollTo({ + left: el.offsetWidth * selectedIndex, + behavior: 'instant' + }); } }, [selectedIndex, selectedKey]); - let prevItem = () => { - let key = state.selectedKey; - let isFirstKey = state.collection.getFirstKey() === key; + if (selectedIndex == null || state.collection.size === 0) { + return null; + } - let prevKey = key ? state.collection.getKeyBefore(key) : null; - if (isFirstKey) { - prevKey = state.collection.getFirstKey(); + 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); } - state.setSelectedKey(prevKey); }; let nextItem = () => { - let key = state.selectedKey; - let isLastKey = state.collection.getLastKey() === key; - let nextKey = key ? state.collection.getKeyAfter(key) : null; - if (isLastKey) { - nextKey = key; + let el = ref.current; + if (el && selectedIndex < size - 1) { + el.scrollLeft = el.offsetWidth * (selectedIndex + 1); } - state.setSelectedKey(nextKey); }; let onScroll = () => { let el = ref.current; - let currentIndex = el ? Math.round(el.scrollLeft / el.offsetWidth) : 0; + if (!el) { + return; + } - // Update the selected tab when scrolling (but do not update when the scrolling is coming from next/prev buttons) - if (selectedIndex !== currentIndex && !isProgrammaticScroll.current) { + 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) { + if (key != null) { state.setSelectedKey(key); } } }; - let onScrollEnd = () => { - isProgrammaticScroll.current = false; - }; - return ( <> - -
- {props.children} + +
+
+ {pages.map(page => ( +
+ {renderPage(page)} +
+ ))} +
- + ); }