Skip to content

Commit ef03ded

Browse files
committed
feat(Gallery): add Gallery component
1 parent ca74270 commit ef03ded

40 files changed

+1350
-0
lines changed

CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
/src/components/StoreBadge @NikitaCG
1414
/src/components/Stories @darkgenius
1515
/src/components/ConfirmDialog @kseniya57
16+
/src/components/Gallery @kseniya57

src/components/Gallery/Gallery.scss

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
@use '../variables';
2+
3+
$block: '.#{variables.$ns}gallery';
4+
$filePreviewBlock: '.g-file-preview';
5+
6+
$horizontalGalleryMargin: 132px;
7+
$verticalGalleryMargin: 28px;
8+
9+
#{$block} {
10+
--g-modal-margin: 0;
11+
12+
&__content {
13+
display: flex;
14+
flex-direction: column;
15+
16+
width: calc(100vw - $horizontalGalleryMargin * 2);
17+
height: calc(100vh - $verticalGalleryMargin * 2);
18+
}
19+
20+
&__header {
21+
display: flex;
22+
align-items: start;
23+
24+
padding: var(--g-spacing-2) var(--g-spacing-3) var(--g-spacing-2) var(--g-spacing-5);
25+
26+
> * {
27+
flex: 1;
28+
min-width: 0;
29+
}
30+
}
31+
32+
&__navigation {
33+
display: flex;
34+
gap: var(--g-spacing-2);
35+
align-items: center;
36+
justify-content: center;
37+
}
38+
39+
&__actions {
40+
display: flex;
41+
gap: var(--g-spacing-1);
42+
align-items: stretch;
43+
justify-content: flex-end;
44+
}
45+
46+
&__active-item-info {
47+
align-self: stretch;
48+
align-items: center;
49+
display: flex;
50+
}
51+
52+
&__body {
53+
position: relative;
54+
55+
display: flex;
56+
align-items: center;
57+
justify-content: center;
58+
59+
flex: 1;
60+
min-height: 0;
61+
62+
padding: 0 var(--g-spacing-2);
63+
}
64+
65+
&__body-navigation-button {
66+
position: absolute;
67+
inset-block: 0 60px;
68+
z-index: 2;
69+
70+
width: 200px;
71+
max-width: 20%;
72+
padding: 0;
73+
margin: 0;
74+
75+
appearance: none;
76+
cursor: pointer;
77+
78+
background-color: transparent;
79+
border: none;
80+
outline: none;
81+
82+
&_direction_start {
83+
inset-inline-start: 0;
84+
85+
cursor:
86+
url('./assets/arrow-left.svg') 2 2,
87+
default;
88+
}
89+
90+
&_direction_end {
91+
inset-inline-end: var(--g-spacing-7);
92+
93+
cursor:
94+
url('./assets/arrow-right.svg') 2 2,
95+
default;
96+
}
97+
}
98+
99+
&__footer {
100+
padding: var(--g-spacing-2) var(--g-spacing-5) var(--g-spacing-4) var(--g-spacing-5);
101+
}
102+
103+
&__preview-list {
104+
display: flex;
105+
gap: var(--g-spacing-2);
106+
align-items: stretch;
107+
overflow: auto hidden;
108+
-ms-overflow-style: none;
109+
scrollbar-width: none;
110+
111+
&::-webkit-scrollbar {
112+
display: none;
113+
}
114+
}
115+
116+
&__preview-list-item {
117+
width: 48px;
118+
min-width: 48px;
119+
height: 48px;
120+
border: 2px solid transparent;
121+
border-radius: var(--g-border-radius-l);
122+
padding: 0;
123+
margin: 0;
124+
125+
appearance: none;
126+
cursor: pointer;
127+
128+
background-color: transparent;
129+
outline: none;
130+
overflow: hidden;
131+
132+
&_selected {
133+
border-color: var(--g-color-line-brand);
134+
}
135+
}
136+
137+
&_mode_full-screen {
138+
overflow: hidden;
139+
140+
--g-modal-border-radius: 0;
141+
142+
#{$block} {
143+
&__content {
144+
width: 100vw;
145+
height: 100vh;
146+
}
147+
148+
&__body {
149+
padding: 0;
150+
}
151+
152+
&__header {
153+
position: absolute;
154+
inset-block-start: 0;
155+
inset-inline: 0;
156+
z-index: 3;
157+
158+
opacity: 0;
159+
160+
&:hover {
161+
opacity: 1;
162+
}
163+
}
164+
165+
&__footer {
166+
position: absolute;
167+
inset-inline: 0;
168+
inset-block-end: 0;
169+
z-index: 1;
170+
171+
opacity: 0;
172+
background-color: rgba(0, 0, 0, 0.45);
173+
174+
&:hover {
175+
opacity: 1;
176+
}
177+
}
178+
}
179+
}
180+
181+
#{$filePreviewBlock} {
182+
&[class] {
183+
width: 100%;
184+
height: 100%;
185+
}
186+
187+
&__card {
188+
width: 100%;
189+
min-width: 100%;
190+
height: 100%;
191+
padding: 0;
192+
}
193+
194+
&__image,
195+
&__icon {
196+
width: 100%;
197+
height: 100%;
198+
}
199+
200+
&__name {
201+
display: none;
202+
}
203+
}
204+
}
205+
206+
.g-root_theme_light,
207+
.g-root_theme_light-hc {
208+
#{$block}_mode_full-screen {
209+
#{$block}__header {
210+
background-color: var(--g-color-private-white-450);
211+
}
212+
}
213+
}
214+
215+
.g-root_theme_dark,
216+
.g-root_theme_dark-hc {
217+
#{$block}_mode_full-screen {
218+
#{$block}__header {
219+
background-color: var(--g-color-private-black-450);
220+
}
221+
}
222+
}

src/components/Gallery/Gallery.tsx

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import * as React from 'react';
2+
3+
import {ArrowLeft, ArrowRight, Xmark} from '@gravity-ui/icons';
4+
import type {ModalProps} from '@gravity-ui/uikit';
5+
import {Button, Icon, Modal, Text} from '@gravity-ui/uikit';
6+
7+
import {block} from '../utils/cn';
8+
9+
import type {GalleryItemProps} from './GalleryItem';
10+
import {GalleryFallbackText} from './components/FallbackText';
11+
import {FullScreenAction} from './components/actions';
12+
import {useFullScreen} from './hooks/useFullScreen';
13+
import type {UseNavigationProps} from './hooks/useNavigation';
14+
import {useNavigation} from './hooks/useNavigation';
15+
import {i18n} from './i18n';
16+
17+
import './Gallery.scss';
18+
19+
const cnGallery = block('gallery');
20+
21+
const emptyItems: GalleryItemProps[] = [];
22+
23+
export type GalleryProps = {
24+
className?: string;
25+
children?: React.ReactElement<GalleryItemProps>[];
26+
emptyMessage?: string;
27+
} & Pick<ModalProps, 'open' | 'container' | 'onOpenChange'> &
28+
Pick<UseNavigationProps, 'initialItemIndex'>;
29+
30+
export const Gallery = ({
31+
initialItemIndex,
32+
open,
33+
onOpenChange,
34+
container,
35+
className,
36+
children,
37+
emptyMessage,
38+
}: GalleryProps) => {
39+
const items = children ? React.Children.map(children, (child) => child.props) : emptyItems;
40+
41+
const {activeItemIndex, setActiveItemIndex, handleGoToNext, handleGoToPrevious} = useNavigation(
42+
{
43+
itemsCount: items.length,
44+
initialItemIndex,
45+
selectedPreviewItemClass: `.${cnGallery('preview-list-item')}_selected`,
46+
},
47+
);
48+
49+
const {fullScreen, setFullScreen} = useFullScreen();
50+
51+
const handleClose = React.useCallback(() => {
52+
onOpenChange?.(false);
53+
setFullScreen(false);
54+
}, [onOpenChange, setFullScreen]);
55+
56+
const handleOpenChange = React.useCallback<NonNullable<ModalProps['onOpenChange']>>(
57+
(nextOpen, event, reason) => {
58+
if (fullScreen && reason === 'escape-key') {
59+
return;
60+
}
61+
62+
onOpenChange?.(nextOpen, event, reason);
63+
setFullScreen(false);
64+
},
65+
[onOpenChange, fullScreen, setFullScreen],
66+
);
67+
68+
const activeItem = items[activeItemIndex] || items[0];
69+
70+
return (
71+
<Modal
72+
container={container}
73+
className={cnGallery({mode: fullScreen ? 'full-screen' : 'default'}, className)}
74+
open={open}
75+
onOpenChange={handleOpenChange}
76+
>
77+
<div className={cnGallery('content')}>
78+
<div className={cnGallery('header')}>
79+
<div className={cnGallery('active-item-info')}>{activeItem?.name}</div>
80+
{items.length > 0 && (
81+
<div className={cnGallery('navigation')}>
82+
<Button size="l" view="flat" onClick={handleGoToPrevious}>
83+
<Icon data={ArrowLeft} />
84+
</Button>
85+
<Text color="secondary" variant="body-1">
86+
{activeItemIndex + 1}/{items.length}
87+
</Text>
88+
<Button size="l" view="flat" onClick={handleGoToNext}>
89+
<Icon data={ArrowRight} />
90+
</Button>
91+
</div>
92+
)}
93+
<div className={cnGallery('actions')}>
94+
{activeItem?.actions}
95+
<FullScreenAction
96+
key="full-screen"
97+
fullScreen={fullScreen}
98+
onUpdateFullScreen={setFullScreen}
99+
/>
100+
<Button
101+
size="l"
102+
view="flat"
103+
aria-label={i18n('close')}
104+
onClick={handleClose}
105+
>
106+
<Icon data={Xmark} />
107+
</Button>
108+
</div>
109+
</div>
110+
<div key={activeItemIndex} className={cnGallery('body')}>
111+
{!items.length && (
112+
<GalleryFallbackText>
113+
{emptyMessage ?? i18n('no-items')}
114+
</GalleryFallbackText>
115+
)}
116+
{activeItem?.view}
117+
{activeItem && !activeItem.interactive && (
118+
<React.Fragment>
119+
<button
120+
onClick={handleGoToPrevious}
121+
type="button"
122+
className={cnGallery('body-navigation-button', {
123+
direction: 'start',
124+
})}
125+
/>
126+
<button
127+
onClick={handleGoToNext}
128+
type="button"
129+
className={cnGallery('body-navigation-button', {
130+
direction: 'end',
131+
})}
132+
/>
133+
</React.Fragment>
134+
)}
135+
</div>
136+
{!fullScreen && (
137+
<div className={cnGallery('footer')}>
138+
<div className={cnGallery('preview-list')}>
139+
{items.map((item, index) => {
140+
const handleClick = () => {
141+
setActiveItemIndex(index);
142+
};
143+
144+
const selected = activeItemIndex === index;
145+
146+
return (
147+
<button
148+
type="button"
149+
key={index}
150+
onClick={handleClick}
151+
className={cnGallery('preview-list-item', {selected})}
152+
>
153+
{item.thumbnail}
154+
</button>
155+
);
156+
})}
157+
</div>
158+
</div>
159+
)}
160+
</div>
161+
</Modal>
162+
);
163+
};

0 commit comments

Comments
 (0)