Skip to content
Open
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
13 changes: 13 additions & 0 deletions src/components/Gallery/Gallery.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
195 changes: 107 additions & 88 deletions src/components/Gallery/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,21 @@ export type GalleryProps = {
className?: string;
children?: React.ReactElement<GalleryItemProps>[] | React.ReactElement<GalleryItemProps>;
emptyMessage?: string;
contained?: boolean;
} & Pick<ModalProps, 'open' | 'container' | 'onOpenChange'> &
Pick<UseNavigationProps, 'initialItemIndex'>;
Pick<UseNavigationProps, 'initialItemIndex' | 'activeItemIndex' | 'onActiveItemIndexChange'>;

export const Gallery = ({
initialItemIndex,
activeItemIndex: activeItemIndexProp,
onActiveItemIndexChange,
open,
onOpenChange,
container,
className,
children,
emptyMessage,
contained,
}: GalleryProps) => {
const isMobile = useMobile();
const {t} = i18n.useTranslation();
Expand All @@ -56,6 +60,8 @@ export const Gallery = ({
const {activeItemIndex, setActiveItemIndex, handleGoToNext, handleGoToPrevious} = useNavigation(
{
initialItemIndex,
activeItemIndex: activeItemIndexProp,
onActiveItemIndexChange,
itemRefs,
},
);
Expand Down Expand Up @@ -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 = (
<div className={cnGallery('content')} {...touchHandlers}>
<GalleryHeader
itemName={activeItem?.name}
actions={activeItem?.actions}
withNavigation={withNavigation}
activeItemIndex={activeItemIndex}
itemsLength={items.length}
fullScreen={fullScreen}
onBackClick={handleBackClick}
onGoToPrevious={handleGoToPrevious}
onGoToNext={handleGoToNext}
onUpdateFullScreen={setFullScreen}
onClose={handleClose}
hidden={hiddenHeader}
interactive={activeItem?.interactive}
withFullScreen={!contained}
/>
<div key={activeItemIndex} className={cnGallery('body')}>
<div
className={cnGallery(BODY_CONTENT_CLASS_NAME, {
switching: isMobile && isSwitching,
})}
>
{!items.length && (
<GalleryFallbackText>{emptyMessage ?? t('no-items')}</GalleryFallbackText>
)}
<GalleryContextProvider
onTap={handleTap}
onViewInteractionChange={setIsViewInteracting}
>
{activeItem?.view}
</GalleryContextProvider>
{showNavigationButtons && (
<React.Fragment>
<NavigationButton onClick={handleGoToPrevious} position="start" />
<NavigationButton onClick={handleGoToNext} position="end" />
</React.Fragment>
)}
</div>
</div>
{showFooter && (
<div className={cnGallery('footer')}>
{withNavigation && (
<div className={cnGallery('preview-list')}>
{items.map((item, index) => {
const handleClick = () => {
setActiveItemIndex(index);
};

const selected = activeItemIndex === index;

// Narrows RefObject<T | null> back to RefObject<T>
// for compatibility with @types/react@18 LegacyRef typing.
const buttonRef = itemRefs[
index
] as React.RefObject<HTMLButtonElement>;

return (
<button
ref={buttonRef}
type="button"
key={item.id ?? index}
onClick={handleClick}
className={cnGallery('preview-list-item', {selected})}
>
{item.thumbnail}
</button>
);
})}
</div>
)}
</div>
)}
</div>
);

if (contained) {
return open ? <div className={rootClassName}>{content}</div> : null;
}

return (
<Modal
container={container}
className={cnGallery(
{
mode,
interactive: isMobile && activeItem?.interactive,
},
className,
)}
className={rootClassName}
open={open}
onOpenChange={handleOpenChange}
style={{
overflow: mode === 'default' ? 'auto' : 'hidden',
}}
>
<div
className={cnGallery('content')}
onTouchStart={isMobile ? handleTouchStart : undefined}
onTouchMove={isMobile ? handleTouchMove : undefined}
onTouchEnd={isMobile ? handleTouchEnd : undefined}
>
<GalleryHeader
itemName={activeItem?.name}
actions={activeItem?.actions}
withNavigation={withNavigation}
activeItemIndex={activeItemIndex}
itemsLength={items.length}
fullScreen={fullScreen}
onBackClick={handleBackClick}
onGoToPrevious={handleGoToPrevious}
onGoToNext={handleGoToNext}
onUpdateFullScreen={setFullScreen}
onClose={handleClose}
hidden={hiddenHeader}
interactive={activeItem?.interactive}
/>
<div key={activeItemIndex} className={cnGallery('body')}>
<div
className={cnGallery(BODY_CONTENT_CLASS_NAME, {
switching: isMobile && isSwitching,
})}
>
{!items.length && (
<GalleryFallbackText>
{emptyMessage ?? t('no-items')}
</GalleryFallbackText>
)}
<GalleryContextProvider
onTap={handleTap}
onViewInteractionChange={setIsViewInteracting}
>
{activeItem?.view}
</GalleryContextProvider>
{showNavigationButtons && (
<React.Fragment>
<NavigationButton onClick={handleGoToPrevious} position="start" />
<NavigationButton onClick={handleGoToNext} position="end" />
</React.Fragment>
)}
</div>
</div>
{showFooter && (
<div className={cnGallery('footer')}>
{withNavigation && (
<div className={cnGallery('preview-list')}>
{items.map((item, index) => {
const handleClick = () => {
setActiveItemIndex(index);
};

const selected = activeItemIndex === index;

// Narrows RefObject<T | null> back to RefObject<T>
// for compatibility with @types/react@18 LegacyRef typing.
const buttonRef = itemRefs[
index
] as React.RefObject<HTMLButtonElement>;

return (
<button
ref={buttonRef}
type="button"
key={index}
onClick={handleClick}
className={cnGallery('preview-list-item', {selected})}
>
{item.thumbnail}
</button>
);
})}
</div>
)}
</div>
)}
</div>
{content}
</Modal>
);
};
1 change: 1 addition & 0 deletions src/components/Gallery/GalleryItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type GalleryItemAction = {
};

export type GalleryItemProps = {
id?: string;
view: React.ReactNode;
thumbnail: React.ReactNode;
name?: React.ReactNode;
Expand Down
56 changes: 48 additions & 8 deletions src/components/Gallery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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
<kbd>Esc</kbd> / outside click — use the header close button.

```tsx
<div style={{position: 'relative', width: 640, height: 400, overflow: 'hidden'}}>
<Gallery open={open} onOpenChange={setOpen} contained>
{/* GalleryItem children */}
</Gallery>
</div>
```

### 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
<Gallery
activeItemIndex={index}
onActiveItemIndexChange={setIndex}
open={open}
onOpenChange={setOpen}
>
{items.map((item) => (
<GalleryItem key={item.id} id={item.id} {...getGalleryItemImage({src: item.src})} />
))}
</Gallery>
```

### Gallery Context

Gallery provides a context for child views to communicate interaction state. See [`GalleryContext` documentation](./contexts/README.md) for details.
Expand Down
41 changes: 41 additions & 0 deletions src/components/Gallery/__stories__/Gallery.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,44 @@ const SmallImagesTemplate: StoryFn<GalleryProps> = () => {
};

export const SmallImages = SmallImagesTemplate.bind({});

const ContainedGalleryTemplate: 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" className={spacing({mb: 3})}>
Open gallery
</Button>
<div
style={{
position: 'relative',
width: 640,
height: 400,
border: '1px solid var(--g-color-line-generic)',
borderRadius: 'var(--g-border-radius-l)',
overflow: 'hidden',
}}
>
<Gallery open={open} onOpenChange={handleToggle} contained>
{images.map((image, index) => (
<GalleryItem
key={index}
{...getGalleryItemImage({src: image.url, name: image.name})}
/>
))}
</Gallery>
</div>
</React.Fragment>
);
};

export const ContainedGallery = ContainedGalleryTemplate.bind({});
Loading