From cfe3760e01af331edaa5ab402ea903e950ddc9f4 Mon Sep 17 00:00:00 2001 From: Artem Nikitin Date: Mon, 6 Jul 2026 18:09:29 +0300 Subject: [PATCH] feat(Gallery): add contained mode and controlled active item index - add `contained` prop to render the gallery inline instead of in a modal - add controlled `activeItemIndex` / `onActiveItemIndexChange` props - add `GalleryItem.id` for identity-based preview keys --- src/components/Gallery/Gallery.scss | 13 + src/components/Gallery/Gallery.tsx | 195 +++++++------- src/components/Gallery/GalleryItem.tsx | 1 + src/components/Gallery/README.md | 56 +++- .../Gallery/__stories__/Gallery.stories.tsx | 41 +++ .../DesktopGalleryHeader.tsx | 14 +- .../GalleryHeader/GalleryHeader.tsx | 3 + .../hooks/__tests__/useNavigation.test.ts | 248 ++++++++++++++++++ src/components/Gallery/hooks/useNavigation.ts | 37 ++- 9 files changed, 502 insertions(+), 106 deletions(-) create mode 100644 src/components/Gallery/hooks/__tests__/useNavigation.test.ts diff --git a/src/components/Gallery/Gallery.scss b/src/components/Gallery/Gallery.scss index f258798c..29ae5edb 100644 --- a/src/components/Gallery/Gallery.scss +++ b/src/components/Gallery/Gallery.scss @@ -81,6 +81,19 @@ $verticalGalleryMargin: 28px; } } + &_contained { + position: absolute; + inset: 0; + + #{$block}__content { + width: 100%; + min-width: 0; + max-width: 100%; + height: 100%; + min-height: 0; + } + } + &_mode_full-screen, &_mode_mobile { --g-modal-border-radius: 0; diff --git a/src/components/Gallery/Gallery.tsx b/src/components/Gallery/Gallery.tsx index 0b37ac6d..f467dbde 100644 --- a/src/components/Gallery/Gallery.tsx +++ b/src/components/Gallery/Gallery.tsx @@ -24,17 +24,21 @@ export type GalleryProps = { className?: string; children?: React.ReactElement[] | React.ReactElement; emptyMessage?: string; + contained?: boolean; } & Pick & - Pick; + Pick; export const Gallery = ({ initialItemIndex, + activeItemIndex: activeItemIndexProp, + onActiveItemIndexChange, open, onOpenChange, container, className, children, emptyMessage, + contained, }: GalleryProps) => { const isMobile = useMobile(); const {t} = i18n.useTranslation(); @@ -56,6 +60,8 @@ export const Gallery = ({ const {activeItemIndex, setActiveItemIndex, handleGoToNext, handleGoToPrevious} = useNavigation( { initialItemIndex, + activeItemIndex: activeItemIndexProp, + onActiveItemIndexChange, itemRefs, }, ); @@ -115,102 +121,115 @@ export const Gallery = ({ const showFooter = !fullScreen && !isMobile; const mode = getMode(isMobile, fullScreen); + const rootClassName = cnGallery( + { + mode, + contained, + interactive: isMobile && activeItem?.interactive, + }, + className, + ); + + const touchHandlers = isMobile + ? { + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + } + : undefined; + + const content = ( +
+
+ ); + + if (contained) { + return open ?
{content}
: null; + } + return ( -
-
+ {content}
); }; diff --git a/src/components/Gallery/GalleryItem.tsx b/src/components/Gallery/GalleryItem.tsx index 7ed9f6f2..1d904aa6 100644 --- a/src/components/Gallery/GalleryItem.tsx +++ b/src/components/Gallery/GalleryItem.tsx @@ -19,6 +19,7 @@ export type GalleryItemAction = { }; export type GalleryItemProps = { + id?: string; view: React.ReactNode; thumbnail: React.ReactNode; name?: React.ReactNode; diff --git a/src/components/Gallery/README.md b/src/components/Gallery/README.md index 55c8e287..398d10b6 100644 --- a/src/components/Gallery/README.md +++ b/src/components/Gallery/README.md @@ -11,22 +11,28 @@ The children of the Gallery should be an array of [GalleryItem with the required - **Swipe Gestures**: Mobile swipe navigation (automatically disabled during zoom interaction) - **Fullscreen Mode**: Toggle fullscreen view - **Custom Actions**: Add custom action buttons for each gallery item +- **Contained Mode**: Render the gallery inside its `container` bounds instead of over the whole viewport +- **Controlled Index**: Drive the active item index from outside via `activeItemIndex` / `onActiveItemIndexChange` ### PropTypes -| Property | Type | Required | Values | Default | Description | -| :--------------- | :------------------------ | :------- | :----- | :------ | :---------------------------- | -| initialItemIndex | `Number` | | | 0 | The initial active item index | -| open | `Boolean` | | | | The modal opened state | -| onOpenChange | `(open: boolean) => void` | | | | The modal toggle handler | -| className | `String` | | | | The modal class | -| container | `HTMLElement` | | | | The modal container | -| emptyMessage | `String` | | | No data | No data message | +| Property | Type | Required | Values | Default | Description | +| :---------------------- | :------------------------ | :------- | :----- | :------ | :-------------------------------------------- | +| initialItemIndex | `Number` | | | 0 | The initial active item index (uncontrolled) | +| activeItemIndex | `Number` | | | | Controlled active item index. | +| onActiveItemIndexChange | `(index: number) => void` | | | | Called with the next index navigation happens | +| open | `Boolean` | | | | The modal opened state | +| onOpenChange | `(open: boolean) => void` | | | | The modal toggle handler | +| className | `String` | | | | The modal class | +| container | `HTMLElement` | | | | The modal container | +| contained | `Boolean` | | | false | Render the gallery inline | +| emptyMessage | `String` | | | No data | No data message | ### GalleryItem | Property | Type | Required | Values | Default | Description | | :---------- | :------------ | :------- | :----- | :------ | :----------------------------------------------------------------------------------------------- | +| id | `String` | | | | Stable identity of the item | | view | `ReactNode` | Yes | | 0 | The gallery item body (displayed in the center of the gallery) | | thumbnail | `ReactNode` | Yes | | | The gallery item thumbnail (displayed as the preview in the footer of the gallery) | | name | `ReactNode` | | | | The gallery item name info (displayed in the gallery header left side) | @@ -51,6 +57,40 @@ Gallery includes built-in zoom functionality for images via the [`useImageZoom`] See [`useImageZoom` documentation](./hooks/useImageZoom/README.md) for more details. +### Contained mode + +Pass `contained` to render the gallery inline (filling its parent) instead of in a viewport-sized +modal. The parent must be positioned (`position: relative`), the `container` prop is ignored, and the +full-screen action is hidden. As it does not use `Modal`, it won't lock body scroll or close on +Esc / outside click — use the header close button. + +```tsx +
+ + {/* GalleryItem children */} + +
+``` + +### Controlled active item index + +The index is uncontrolled by default (seeded by `initialItemIndex`). Pass `activeItemIndex` + +`onActiveItemIndexChange` to control it. When items can be added/removed, give each `GalleryItem` a +stable `id` so the active item is tracked by identity rather than by position. + +```tsx + + {items.map((item) => ( + + ))} + +``` + ### Gallery Context Gallery provides a context for child views to communicate interaction state. See [`GalleryContext` documentation](./contexts/README.md) for details. diff --git a/src/components/Gallery/__stories__/Gallery.stories.tsx b/src/components/Gallery/__stories__/Gallery.stories.tsx index 0c5e94ee..d2eb17ee 100644 --- a/src/components/Gallery/__stories__/Gallery.stories.tsx +++ b/src/components/Gallery/__stories__/Gallery.stories.tsx @@ -373,3 +373,44 @@ const SmallImagesTemplate: StoryFn = () => { }; export const SmallImages = SmallImagesTemplate.bind({}); + +const ContainedGalleryTemplate: StoryFn = () => { + const [open, setOpen] = React.useState(false); + + const handleToggle = React.useCallback(() => { + setOpen(false); + }, []); + + const handleOpen = React.useCallback(() => { + setOpen(true); + }, []); + + return ( + + +
+ + {images.map((image, index) => ( + + ))} + +
+
+ ); +}; + +export const ContainedGallery = ContainedGalleryTemplate.bind({}); diff --git a/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx b/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx index 7f517b0a..4de4280e 100644 --- a/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx +++ b/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx @@ -24,6 +24,7 @@ export type DesktopGalleryHeaderProps = { onGoToNext: () => void; onUpdateFullScreen: React.Dispatch>; onClose: () => void; + withFullScreen?: boolean; }; export const DesktopGalleryHeader = ({ @@ -37,6 +38,7 @@ export const DesktopGalleryHeader = ({ onGoToNext, onUpdateFullScreen, onClose, + withFullScreen = true, }: DesktopGalleryHeaderProps) => { const direction = useDirection(); const {t} = i18n.useTranslation(); @@ -103,11 +105,13 @@ export const DesktopGalleryHeader = ({ ) ); })} - + {withFullScreen && ( + + )} diff --git a/src/components/Gallery/components/GalleryHeader/GalleryHeader.tsx b/src/components/Gallery/components/GalleryHeader/GalleryHeader.tsx index d9a28f39..b4b997df 100644 --- a/src/components/Gallery/components/GalleryHeader/GalleryHeader.tsx +++ b/src/components/Gallery/components/GalleryHeader/GalleryHeader.tsx @@ -20,6 +20,7 @@ export type GalleryHeaderProps = { onClose: () => void; hidden?: boolean; interactive?: boolean; + withFullScreen?: boolean; }; export const GalleryHeader = ({ @@ -36,6 +37,7 @@ export const GalleryHeader = ({ onClose, hidden, interactive, + withFullScreen = true, }: GalleryHeaderProps) => { const isMobile = useMobile(); @@ -66,6 +68,7 @@ export const GalleryHeader = ({ onGoToNext={onGoToNext} onUpdateFullScreen={onUpdateFullScreen} onClose={onClose} + withFullScreen={withFullScreen} /> ); }; diff --git a/src/components/Gallery/hooks/__tests__/useNavigation.test.ts b/src/components/Gallery/hooks/__tests__/useNavigation.test.ts new file mode 100644 index 00000000..c4504304 --- /dev/null +++ b/src/components/Gallery/hooks/__tests__/useNavigation.test.ts @@ -0,0 +1,248 @@ +import * as React from 'react'; + +import {act, renderHook} from '@testing-library/react'; + +import {useNavigation} from '../useNavigation'; + +const createItemRefs = (count: number): React.RefObject[] => + Array.from({length: count}, () => ({current: document.createElement('button')})); + +const dispatchKey = (key: string) => { + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', {key})); + }); +}; + +describe('useNavigation', () => { + beforeAll(() => { + HTMLElement.prototype.scrollIntoView = jest.fn(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('uncontrolled mode', () => { + it('should default to index 0', () => { + const {result} = renderHook(() => useNavigation({itemRefs: createItemRefs(3)})); + + expect(result.current.activeItemIndex).toBe(0); + }); + + it('should respect initialItemIndex', () => { + const {result} = renderHook(() => + useNavigation({initialItemIndex: 2, itemRefs: createItemRefs(3)}), + ); + + expect(result.current.activeItemIndex).toBe(2); + }); + + it('should update the active index via setActiveItemIndex', () => { + const {result} = renderHook(() => useNavigation({itemRefs: createItemRefs(3)})); + + act(() => { + result.current.setActiveItemIndex(2); + }); + + expect(result.current.activeItemIndex).toBe(2); + }); + + it('should support a functional updater in setActiveItemIndex', () => { + const {result} = renderHook(() => + useNavigation({initialItemIndex: 1, itemRefs: createItemRefs(3)}), + ); + + act(() => { + result.current.setActiveItemIndex((previous) => previous + 1); + }); + + expect(result.current.activeItemIndex).toBe(2); + }); + + it('should go to the next item', () => { + const {result} = renderHook(() => useNavigation({itemRefs: createItemRefs(3)})); + + act(() => { + result.current.handleGoToNext(); + }); + + expect(result.current.activeItemIndex).toBe(1); + }); + + it('should wrap around to the first item after the last one', () => { + const {result} = renderHook(() => + useNavigation({initialItemIndex: 2, itemRefs: createItemRefs(3)}), + ); + + act(() => { + result.current.handleGoToNext(); + }); + + expect(result.current.activeItemIndex).toBe(0); + }); + + it('should go to the previous item', () => { + const {result} = renderHook(() => + useNavigation({initialItemIndex: 2, itemRefs: createItemRefs(3)}), + ); + + act(() => { + result.current.handleGoToPrevious(); + }); + + expect(result.current.activeItemIndex).toBe(1); + }); + + it('should wrap around to the last item before the first one', () => { + const {result} = renderHook(() => useNavigation({itemRefs: createItemRefs(3)})); + + act(() => { + result.current.handleGoToPrevious(); + }); + + expect(result.current.activeItemIndex).toBe(2); + }); + + it('should call onActiveItemIndexChange with the next index', () => { + const onActiveItemIndexChange = jest.fn(); + const {result} = renderHook(() => + useNavigation({onActiveItemIndexChange, itemRefs: createItemRefs(3)}), + ); + + act(() => { + result.current.handleGoToNext(); + }); + + expect(onActiveItemIndexChange).toHaveBeenCalledWith(1); + }); + }); + + describe('index clamping', () => { + it('should clamp an initialItemIndex above the range', () => { + const {result} = renderHook(() => + useNavigation({initialItemIndex: 10, itemRefs: createItemRefs(3)}), + ); + + expect(result.current.activeItemIndex).toBe(2); + }); + + it('should clamp a negative initialItemIndex to 0', () => { + const {result} = renderHook(() => + useNavigation({initialItemIndex: -5, itemRefs: createItemRefs(3)}), + ); + + expect(result.current.activeItemIndex).toBe(0); + }); + + it('should return 0 when there are no items', () => { + const {result} = renderHook(() => + useNavigation({initialItemIndex: 2, itemRefs: createItemRefs(0)}), + ); + + expect(result.current.activeItemIndex).toBe(0); + }); + }); + + describe('controlled mode', () => { + it('should use the controlled activeItemIndex', () => { + const {result} = renderHook(() => + useNavigation({activeItemIndex: 1, itemRefs: createItemRefs(3)}), + ); + + expect(result.current.activeItemIndex).toBe(1); + }); + + it('should clamp the controlled activeItemIndex', () => { + const {result} = renderHook(() => + useNavigation({activeItemIndex: 10, itemRefs: createItemRefs(3)}), + ); + + expect(result.current.activeItemIndex).toBe(2); + }); + + it('should not change the value internally but notify via onActiveItemIndexChange', () => { + const onActiveItemIndexChange = jest.fn(); + const {result} = renderHook(() => + useNavigation({ + activeItemIndex: 1, + onActiveItemIndexChange, + itemRefs: createItemRefs(3), + }), + ); + + act(() => { + result.current.handleGoToNext(); + }); + + expect(onActiveItemIndexChange).toHaveBeenCalledWith(2); + expect(result.current.activeItemIndex).toBe(1); + }); + + it('should reflect a new controlled activeItemIndex on rerender', () => { + const {result, rerender} = renderHook( + ({activeItemIndex}) => + useNavigation({activeItemIndex, itemRefs: createItemRefs(3)}), + {initialProps: {activeItemIndex: 0}}, + ); + + expect(result.current.activeItemIndex).toBe(0); + + rerender({activeItemIndex: 2}); + + expect(result.current.activeItemIndex).toBe(2); + }); + }); + + describe('keyboard navigation', () => { + it('should go to the next item on ArrowRight', () => { + const {result} = renderHook(() => useNavigation({itemRefs: createItemRefs(3)})); + + dispatchKey('ArrowRight'); + + expect(result.current.activeItemIndex).toBe(1); + }); + + it('should go to the previous item on ArrowLeft', () => { + const {result} = renderHook(() => + useNavigation({initialItemIndex: 2, itemRefs: createItemRefs(3)}), + ); + + dispatchKey('ArrowLeft'); + + expect(result.current.activeItemIndex).toBe(1); + }); + + it('should ignore unrelated keys', () => { + const {result} = renderHook(() => + useNavigation({initialItemIndex: 1, itemRefs: createItemRefs(3)}), + ); + + dispatchKey('Enter'); + + expect(result.current.activeItemIndex).toBe(1); + }); + + it('should remove the keydown listener on unmount', () => { + const onActiveItemIndexChange = jest.fn(); + const {unmount} = renderHook(() => + useNavigation({onActiveItemIndexChange, itemRefs: createItemRefs(3)}), + ); + + unmount(); + dispatchKey('ArrowRight'); + + expect(onActiveItemIndexChange).not.toHaveBeenCalled(); + }); + }); + + describe('scroll into view', () => { + it('should scroll the active preview into view', () => { + const itemRefs = createItemRefs(3); + const scrollIntoView = itemRefs[2].current!.scrollIntoView as jest.Mock; + + renderHook(() => useNavigation({initialItemIndex: 2, itemRefs})); + + expect(scrollIntoView).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/Gallery/hooks/useNavigation.ts b/src/components/Gallery/hooks/useNavigation.ts index 458da987..9a4459b5 100644 --- a/src/components/Gallery/hooks/useNavigation.ts +++ b/src/components/Gallery/hooks/useNavigation.ts @@ -2,24 +2,51 @@ import * as React from 'react'; export type UseNavigationProps = { initialItemIndex?: number; + activeItemIndex?: number; + onActiveItemIndexChange?: (index: number) => void; itemRefs: React.RefObject[]; }; -export function useNavigation({initialItemIndex = 0, itemRefs}: UseNavigationProps) { - const [activeItemIndex, setActiveItemIndex] = React.useState(initialItemIndex); - +export function useNavigation({ + initialItemIndex = 0, + activeItemIndex: controlledActiveItemIndex, + onActiveItemIndexChange, + itemRefs, +}: UseNavigationProps) { const itemsCount = itemRefs.length; + const isControlled = controlledActiveItemIndex !== undefined; + + const [uncontrolledItemIndex, setUncontrolledItemIndex] = React.useState(initialItemIndex); + + const rawItemIndex = isControlled ? controlledActiveItemIndex : uncontrolledItemIndex; + + const activeItemIndex = + itemsCount === 0 ? 0 : Math.min(Math.max(rawItemIndex, 0), itemsCount - 1); + + const setActiveItemIndex = React.useCallback( + (update: number | ((previousActiveItemIndex: number) => number)) => { + const nextActiveItemIndex = + typeof update === 'function' ? update(activeItemIndex) : update; + + if (!isControlled) { + setUncontrolledItemIndex(nextActiveItemIndex); + } + + onActiveItemIndexChange?.(nextActiveItemIndex); + }, + [activeItemIndex, isControlled, onActiveItemIndexChange], + ); const handleGoToPrevious = React.useCallback(() => { setActiveItemIndex((previousActiveItemIndex) => { const nextActiveItemIndex = previousActiveItemIndex - 1; return nextActiveItemIndex > -1 ? nextActiveItemIndex : itemsCount - 1; }); - }, [itemsCount]); + }, [itemsCount, setActiveItemIndex]); const handleGoToNext = React.useCallback(() => { setActiveItemIndex((previousActiveItemIndex) => (previousActiveItemIndex + 1) % itemsCount); - }, [itemsCount]); + }, [itemsCount, setActiveItemIndex]); React.useEffect(() => { const activeItemPreview = itemRefs[activeItemIndex]?.current;