diff --git a/src/components/Gallery/Gallery.scss b/src/components/Gallery/Gallery.scss index f258798c..49d7b075 100644 --- a/src/components/Gallery/Gallery.scss +++ b/src/components/Gallery/Gallery.scss @@ -81,6 +81,22 @@ $verticalGalleryMargin: 28px; } } + &_view_inline { + width: 100%; + height: 100%; + + &, + &#{$block}_mode_mobile { + #{$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..2d1556ed 100644 --- a/src/components/Gallery/Gallery.tsx +++ b/src/components/Gallery/Gallery.tsx @@ -11,31 +11,29 @@ import {BODY_CONTENT_CLASS_NAME, cnGallery} from './constants'; import {GalleryContextProvider} from './contexts/GalleryContext'; import {useFullScreen} from './hooks/useFullScreen'; import {useMobileGestures} from './hooks/useMobileGestures/useMobileGestures'; -import type {UseNavigationProps} from './hooks/useNavigation'; import {useNavigation} from './hooks/useNavigation'; import {i18n} from './i18n'; +import type {GalleryProps} from './types'; import {getMode} from './utils/getMode'; import './Gallery.scss'; const emptyItems: GalleryItemProps[] = []; -export type GalleryProps = { - className?: string; - children?: React.ReactElement[] | React.ReactElement; - emptyMessage?: string; -} & Pick & - Pick; - export const Gallery = ({ initialItemIndex, - open, - onOpenChange, - container, + activeItemIndex: activeItemIndexProp, + onActiveItemIndexChange, className, children, emptyMessage, + view = 'modal', + open, + onOpenChange, + container, }: GalleryProps) => { + const inlineGalleryContainerRef = React.useRef(null); + const isMobile = useMobile(); const {t} = i18n.useTranslation(); @@ -56,7 +54,10 @@ export const Gallery = ({ const {activeItemIndex, setActiveItemIndex, handleGoToNext, handleGoToPrevious} = useNavigation( { initialItemIndex, + activeItemIndex: activeItemIndexProp, + onActiveItemIndexChange, itemRefs, + keyboardScope: inlineGalleryContainerRef, }, ); @@ -70,10 +71,6 @@ export const Gallery = ({ const {fullScreen, setFullScreen} = useFullScreen(); - const handleBackClick = React.useCallback(() => { - onOpenChange?.(false); - }, [onOpenChange]); - const handleClose = React.useCallback(() => { onOpenChange?.(false); setFullScreen(false); @@ -115,102 +112,117 @@ export const Gallery = ({ const showFooter = !fullScreen && !isMobile; const mode = getMode(isMobile, fullScreen); + const rootClassName = cnGallery( + { + mode, + view, + interactive: isMobile && activeItem?.interactive, + }, + className, + ); + + const touchHandlers = isMobile + ? { + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + } + : undefined; + + const content = ( +
+
+ ); + + if (view === 'inline') { + return ( +
+ {content} +
+ ); + } + 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..ed69a820 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 +- **Inline View**: Render the gallery in place within its parent instead of in a modal overlay +- **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 | +| view | `String` | | `modal` `inline` | modal | Render in a modal overlay | +| className | `String` | | | | The root class | +| emptyMessage | `String` | | | No data | No data message | +| open | `Boolean` | | | | `modal` only. The modal opened state | +| onOpenChange | `(open: boolean) => void` | | | | `modal` only. The modal toggle handler | +| container | `HTMLElement` | | | | `modal` only. The modal container | ### 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,44 @@ Gallery includes built-in zoom functionality for images via the [`useImageZoom`] See [`useImageZoom` documentation](./hooks/useImageZoom/README.md) for more details. +### Inline view + +Pass `view="inline"` to render the gallery in place instead of a `modal`. It fills its parent +without imposing any `position`, so give that parent a resolvable size. `open` / `onOpenChange` / +`container` don't apply and there is no built-in close button — the parent controls visibility by +unmounting. For an in-header dismiss control, add it as a `GalleryItem` action. + +```tsx +
+ + , onClick: onClose}]} + /> + +
+``` + +### Controlled active item index + +The index is uncontrolled by default (seeded by `initialItemIndex`). Pass `activeItemIndex` + +`onActiveItemIndexChange` to control it — the index is positional, so when items are added/removed +it's up to the parent to remap `activeItemIndex` to the item it wants to keep active. Giving each +`GalleryItem` a stable `id` keeps its preview reconciled by identity across such changes. + +```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. @@ -125,10 +169,6 @@ const ImagesGallery = () => { const container = usePortalContainer(); - const handleClose = React.useCallback(() => { - setOpen(false); - }, []); - const handleOpen = React.useCallback(() => { setOpen(true); }, []); @@ -138,7 +178,7 @@ const ImagesGallery = () => { - + {images.map((image, index) => ( ))} @@ -169,10 +209,6 @@ const GalleryTemplate: StoryFn = () => { const container = usePortalContainer(); - const handleClose = React.useCallback(() => { - setOpen(false); - }, []); - const handleOpen = React.useCallback(() => { setOpen(true); }, []); @@ -218,7 +254,7 @@ const GalleryTemplate: StoryFn = () => { Open gallery - + {files.map((file, index) => ( = () => { }; export const SmallImages = SmallImagesTemplate.bind({}); + +const InlineGalleryTemplate: StoryFn = () => { + return ( +
+ + {images.map((image, index) => ( + + ))} + +
+ ); +}; + +export const InlineGallery = InlineGalleryTemplate.bind({}); diff --git a/src/components/Gallery/__tests__/Gallery.test.tsx b/src/components/Gallery/__tests__/Gallery.test.tsx new file mode 100644 index 00000000..376d7af9 --- /dev/null +++ b/src/components/Gallery/__tests__/Gallery.test.tsx @@ -0,0 +1,92 @@ +import {fireEvent, render, screen} from '../../../../test-utils/utils'; +import {Gallery} from '../Gallery'; +import {GalleryItem} from '../GalleryItem'; + +const renderItems = () => [ + Item A} thumbnail={thumb A} name="A" />, + Item B} thumbnail={thumb B} name="B" />, +]; + +describe('Gallery', () => { + beforeAll(() => { + HTMLElement.prototype.scrollIntoView = jest.fn(); + }); + + describe('modal view', () => { + it('should render the content and a close button when open', () => { + render( + + {renderItems()} + , + ); + + expect(screen.getByText('Item A')).toBeInTheDocument(); + expect(screen.getByLabelText('Close')).toBeInTheDocument(); + }); + + it('should not render the content when closed', () => { + render( + + {renderItems()} + , + ); + + expect(screen.queryByText('Item A')).not.toBeInTheDocument(); + }); + + it('should call onOpenChange(false) when the close button is pressed', () => { + const onOpenChange = jest.fn(); + render( + + {renderItems()} + , + ); + + fireEvent.click(screen.getByLabelText('Close')); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + describe('inline view', () => { + it('should render the content without a Modal, regardless of open', () => { + render({renderItems()}); + + expect(screen.getByText('Item A')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should never render a built-in close button', () => { + render({renderItems()}); + + expect(screen.queryByLabelText('Close')).not.toBeInTheDocument(); + }); + + it('should render item actions in the header, letting consumers provide their own dismiss control', () => { + const onClick = jest.fn(); + render( + + Item A} + thumbnail={thumb A} + name="A" + actions={[ + { + id: 'dismiss', + title: 'Dismiss', + icon: x, + onClick, + }, + ]} + /> + , + ); + + const actionButton = screen.getByLabelText('Dismiss'); + fireEvent.click(actionButton); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx b/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx index 7f517b0a..c30725e4 100644 --- a/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx +++ b/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx @@ -22,8 +22,8 @@ export type DesktopGalleryHeaderProps = { fullScreen: boolean; onGoToPrevious: () => void; onGoToNext: () => void; - onUpdateFullScreen: React.Dispatch>; - onClose: () => void; + onUpdateFullScreen?: React.Dispatch>; + onClose?: () => void; }; export const DesktopGalleryHeader = ({ @@ -50,13 +50,18 @@ export const DesktopGalleryHeader = ({
{itemName}
{withNavigation && (
- {activeItemIndex + 1} / {itemsLength} -
@@ -103,14 +108,18 @@ export const DesktopGalleryHeader = ({ ) ); })} - - + {onUpdateFullScreen && ( + + )} + {onClose && ( + + )} ); diff --git a/src/components/Gallery/components/GalleryHeader/GalleryHeader.tsx b/src/components/Gallery/components/GalleryHeader/GalleryHeader.tsx index d9a28f39..a6097fbc 100644 --- a/src/components/Gallery/components/GalleryHeader/GalleryHeader.tsx +++ b/src/components/Gallery/components/GalleryHeader/GalleryHeader.tsx @@ -13,13 +13,12 @@ export type GalleryHeaderProps = { activeItemIndex: number; itemsLength: number; fullScreen: boolean; - onBackClick: () => void; onGoToPrevious: () => void; onGoToNext: () => void; - onUpdateFullScreen: React.Dispatch>; - onClose: () => void; hidden?: boolean; interactive?: boolean; + onClose?: () => void; + onUpdateFullScreen?: React.Dispatch>; }; export const GalleryHeader = ({ @@ -29,7 +28,6 @@ export const GalleryHeader = ({ activeItemIndex, itemsLength, fullScreen, - onBackClick, onGoToPrevious, onGoToNext, onUpdateFullScreen, @@ -47,7 +45,7 @@ export const GalleryHeader = ({ withNavigation={withNavigation} activeItemIndex={activeItemIndex} itemsLength={itemsLength} - onBackClick={onBackClick} + onBackClick={onClose} hidden={hidden} interactive={interactive} /> diff --git a/src/components/Gallery/components/MobileGalleryHeader/MobileGalleryHeader.tsx b/src/components/Gallery/components/MobileGalleryHeader/MobileGalleryHeader.tsx index dc3b3e35..4133b947 100644 --- a/src/components/Gallery/components/MobileGalleryHeader/MobileGalleryHeader.tsx +++ b/src/components/Gallery/components/MobileGalleryHeader/MobileGalleryHeader.tsx @@ -19,9 +19,9 @@ export type MobileGalleryHeaderProps = { withNavigation: boolean; activeItemIndex: number; itemsLength: number; - onBackClick: () => void; hidden?: boolean; interactive?: boolean; + onBackClick?: () => void; }; export const MobileGalleryHeader = ({ @@ -56,15 +56,17 @@ export const MobileGalleryHeader = ({ interactive, })} > - + {onBackClick && ( + + )} {withNavigation && ( diff --git a/src/components/Gallery/components/NavigationButton/NavigationButton.tsx b/src/components/Gallery/components/NavigationButton/NavigationButton.tsx index 7543e61d..c55f1017 100644 --- a/src/components/Gallery/components/NavigationButton/NavigationButton.tsx +++ b/src/components/Gallery/components/NavigationButton/NavigationButton.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import {useDirection} from '@gravity-ui/uikit'; import {block} from '../../../utils/cn'; +import {i18n} from '../../i18n'; import './NavigationButton.scss'; @@ -15,11 +16,13 @@ export type NavigationButtonProps = { export const NavigationButton = ({position, onClick}: NavigationButtonProps) => { const direction = useDirection(); + const {t} = i18n.useTranslation(); return (