Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/components/Gallery/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {GalleryFallbackText} from './components/FallbackText';
import {GalleryHeader} from './components/GalleryHeader/GalleryHeader';
import {NavigationButton} from './components/NavigationButton/NavigationButton';
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';
Expand Down Expand Up @@ -46,6 +47,7 @@ export const Gallery = ({
);

const [hiddenHeader, setHiddenHeader] = React.useState(false);
const [isViewInteracting, setIsViewInteracting] = React.useState(false);

React.useEffect(() => {
setItemRefs(Array.from({length: itemsCount}, () => React.createRef()));
Expand All @@ -58,6 +60,14 @@ export const Gallery = ({
},
);

React.useEffect(() => {
setIsViewInteracting(false);
}, [activeItemIndex]);

React.useEffect(() => {
if (isViewInteracting) setHiddenHeader(true);
}, [isViewInteracting]);

const {fullScreen, setFullScreen} = useFullScreen();

const handleBackClick = React.useCallback(() => {
Expand Down Expand Up @@ -95,12 +105,13 @@ export const Gallery = ({
onSwipeLeft: handleGoToNext,
onSwipeRight: handleGoToPrevious,
onTap: handleTap,
disabled: isViewInteracting,
});

const withNavigation = items.length > 1;

const showNavigationButtons =
withNavigation && !isMobile && activeItem && !activeItem.interactive;
withNavigation && !isMobile && activeItem && !activeItem.interactive && !isViewInteracting;
const showFooter = !fullScreen && !isMobile;
const mode = getMode(isMobile, fullScreen);

Expand Down Expand Up @@ -152,7 +163,12 @@ export const Gallery = ({
{emptyMessage ?? t('no-items')}
</GalleryFallbackText>
)}
{activeItem?.view}
<GalleryContextProvider
onTap={handleTap}
onViewInteractionChange={setIsViewInteracting}
>
{activeItem?.view}
</GalleryContextProvider>
{showNavigationButtons && (
<React.Fragment>
<NavigationButton onClick={handleGoToPrevious} position="start" />
Expand Down
8 changes: 7 additions & 1 deletion src/components/Gallery/GalleryItem.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import * as React from 'react';
import type * as React from 'react';

import {ButtonProps} from '@gravity-ui/uikit';

import type {TProps, WithTFn} from './i18n';

export type GalleryItemAction = {
id: string;
title: string;
/** @internal */
__titleT?: WithTFn;
hotkey?: string;
onClick?: () => void;
href?: string;
icon: React.ReactNode;
render?: (props: ButtonProps) => React.ReactNode;
/** @internal */
__renderT?: (props: ButtonProps, tProps: TProps) => React.ReactNode;
};

export type GalleryItemProps = {
Expand Down
30 changes: 30 additions & 0 deletions src/components/Gallery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ The base component for rendering galleries of any type of data.
The component is responsible for the gallery navigation (keyboard arrows, body side click and header arrow click).
The children of the Gallery should be an array of [GalleryItem with the required properties](#GalleryItem) for rendering the gallery item view.

### Features

- **Navigation**: Keyboard arrows, body side click, and header arrow click
- **Image Zoom**: Built-in zoom and pan functionality for images (desktop and mobile)
- **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

### PropTypes

| Property | Type | Required | Values | Default | Description |
Expand All @@ -25,6 +33,28 @@ The children of the Gallery should be an array of [GalleryItem with the required
| actions | `ReactNode[]` | | | | The array of the gallery item action buttons |
| interactive | `boolean` | | | | Provide true if the gallery item is interactive and the navigation by body click should not work |

### Image Zoom

Gallery includes built-in zoom functionality for images via the [`useImageZoom`](./hooks/useImageZoom/README.md) hook:

**Desktop:**

- Click to toggle 1x ↔ 2x zoom
- Drag to pan when zoomed

**Mobile:**

- Double tap to toggle 1x ↔ 3x zoom
- Pinch to zoom (1.0 - 3.0)
- Single finger drag to pan when zoomed
- Swipe gestures automatically disabled during zoom interaction

See [`useImageZoom` documentation](./hooks/useImageZoom/README.md) for more details.

### Gallery Context

Gallery provides a context for child views to communicate interaction state. See [`GalleryContext` documentation](./contexts/README.md) for details.

### Default gallery item props

We export some utility functions for getting the gallery item props:
Expand Down
36 changes: 36 additions & 0 deletions src/components/Gallery/__stories__/Gallery.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,39 @@ const SingleItemGalleryTemplate: StoryFn<GalleryProps> = () => {
};

export const SingleItemGallery = SingleItemGalleryTemplate.bind({});

const SmallImagesTemplate: StoryFn<GalleryProps> = () => {
const [open, setOpen] = React.useState(false);

const handleToggle = React.useCallback(() => {
setOpen(false);
}, []);

const handleOpen = React.useCallback(() => {
setOpen(true);
}, []);

return (
<React.Fragment>
<Button onClick={handleOpen} view="action" size="l">
Open gallery
</Button>
<Gallery open={open} onOpenChange={handleToggle}>
<GalleryItem
{...getGalleryItemImage({
src: 'https://i.pinimg.com/236x/6d/4a/02/6d4a022b29ce165692672be0d35ec1df.jpg',
name: '1',
})}
/>
<GalleryItem
{...getGalleryItemImage({
src: 'https://i.pinimg.com/236x/5a/ec/22/5aec2220c6ebe1869b059801e2107044.jpg',
name: '2',
})}
/>
</Gallery>
</React.Fragment>
);
};

export const SmallImages = SmallImagesTemplate.bind({});
Original file line number Diff line number Diff line change
Expand Up @@ -63,25 +63,44 @@ export const DesktopGalleryHeader = ({
)}
<div className={cnDesktopGalleryHeader('actions')}>
{actions?.map((action) => {
const title = action.__titleT ? action.__titleT({t}) : action.title;
const buttonProps: ButtonProps = {
type: 'button',
size: 'l',
view: 'flat',
onClick: action.onClick,
href: action.href,
target: '__blank',
'aria-label': action.title,
'aria-label': title,
children: action.icon,
};

return action.render ? (
<React.Fragment key={action.id}>
{action.render(buttonProps)}
</React.Fragment>
) : (
<ActionTooltip key={action.id} title={action.title} hotkey={action.hotkey}>
<Button {...buttonProps} />
</ActionTooltip>
const render = () => {
if (action.__renderT) {
return (
<React.Fragment key={action.id}>
{action.__renderT(buttonProps, {t})}
</React.Fragment>
);
}

if (action.render) {
return (
<React.Fragment key={action.id}>
{action.render(buttonProps)}
</React.Fragment>
);
}

return null;
};

return (
render() ?? (
<ActionTooltip key={action.id} title={title} hotkey={action.hotkey}>
<Button {...buttonProps} />
</ActionTooltip>
)
);
})}
<FullScreenAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {Button, ButtonProps, Flex, List, Sheet} from '@gravity-ui/uikit';

import {block} from '../../../utils/cn';
import type {GalleryItemAction, GalleryItemProps} from '../../GalleryItem';
import {i18n} from '../../i18n';

import './MobileGalleryActions.scss';

Expand All @@ -16,35 +17,55 @@ export type MobileGalleryActionsProps = {
};

export const MobileGalleryActions = ({open, actions = [], onClose}: MobileGalleryActionsProps) => {
const renderListItem = React.useCallback((item: GalleryItemAction) => {
const buttonProps: ButtonProps = {
type: 'button',
size: 'xl',
view: 'flat',
onClick: item.onClick,
href: item.href,
target: '__blank',
'aria-label': item.title,
className: cnMobileGalleryActions('list-item'),
width: 'max',
children: (
<Flex alignItems="center" gap={3} className={cnMobileGalleryActions('custom-item')}>
{item.icon}
{item.title}
</Flex>
),
};

return (
<React.Fragment>
{item.render ? (
<React.Fragment key={item.id}>{item.render(buttonProps)}</React.Fragment>
) : (
<Button {...buttonProps} />
)}
</React.Fragment>
);
}, []);
const {t} = i18n.useTranslation();

const renderListItem = React.useCallback(
(item: GalleryItemAction) => {
const title = item.__titleT ? item.__titleT({t}) : item.title;
const buttonProps: ButtonProps = {
type: 'button',
size: 'xl',
view: 'flat',
onClick: item.onClick,
href: item.href,
target: '__blank',
'aria-label': title,
className: cnMobileGalleryActions('list-item'),
width: 'max',
children: (
<Flex
alignItems="center"
gap={3}
className={cnMobileGalleryActions('custom-item')}
>
{item.icon}
{title}
</Flex>
),
};

const render = () => {
if (item.__renderT) {
return (
<React.Fragment key={item.id}>
{item.__renderT(buttonProps, {t})}
</React.Fragment>
);
}

if (item.render) {
return (
<React.Fragment key={item.id}>{item.render(buttonProps)}</React.Fragment>
);
}

return null;
};

return render() ?? <Button key={item.id} {...buttonProps} />;
},
[t],
);

return (
<Sheet className={cnMobileGalleryActions()} visible={open} onClose={onClose}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,32 @@
$block: '.#{variables.$ns}gallery-image-view';

#{$block} {
max-width: 100%;
max-height: 100%;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;

&__image {
max-width: 100%;
max-height: 100%;
transition: transform 0.3s ease-out;
transform-origin: center center;
user-select: none;
}

&__spin {
position: absolute;
}

&_mobile {
user-select: none;
&_mobile #{$block}__image {
// Touch optimizations
touch-action: manipulation;
-webkit-user-drag: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
will-change: transform;
}
}
Loading
Loading