@semcore/carousel 3.17.1-prerelease.1 → 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,676 @@
1
+ import React from 'react';
2
+ import createComponent, { Component, sstyled, Root } from '@semcore/core';
3
+ import Button from '@semcore/button';
4
+ import Modal from '@semcore/modal';
5
+ import { Box, Flex } from '@semcore/flex-box';
6
+ import ChevronRight from '@semcore/icon/ChevronRight/l';
7
+ import ChevronLeft from '@semcore/icon/ChevronLeft/l';
8
+ import uniqueIDEnhancement from '@semcore/utils/lib/uniqueID';
9
+ import i18nEnhance from '@semcore/utils/lib/enhances/i18nEnhance';
10
+ import { localizedMessages } from './translations/__intergalactic-dynamic-locales';
11
+ import logger from '@semcore/utils/lib/logger';
12
+ import style from './style/carousel.shadow.css';
13
+ import CarouselType, {
14
+ CarouselProps,
15
+ CarouselState,
16
+ CarouselContext,
17
+ CarouselItem,
18
+ CarouselItemProps,
19
+ CarouselButtonProps,
20
+ CarouselIndicatorsProps,
21
+ CarouselIndicatorProps,
22
+ } from './Carousel.types';
23
+ import { BoxProps } from '@semcore/flex-box';
24
+ import { findAllComponents } from '@semcore/utils/lib/findComponent';
25
+ import { createBreakpoints } from '@semcore/breakpoints';
26
+
27
+ const MAP_TRANSFORM: Record<string, 'left' | 'right'> = {
28
+ ArrowLeft: 'left',
29
+ ArrowRight: 'right',
30
+ };
31
+
32
+ const enhance = {
33
+ uid: uniqueIDEnhancement(),
34
+ getI18nText: i18nEnhance(localizedMessages),
35
+ };
36
+ const media = ['(min-width: 481px)', '(max-width: 480px)'];
37
+ const BreakPoints = createBreakpoints(media);
38
+ const isSmallScreen = (index: number) => index === 1;
39
+
40
+ class CarouselRoot extends Component<
41
+ CarouselProps,
42
+ CarouselContext,
43
+ CarouselState,
44
+ typeof enhance
45
+ > {
46
+ static displayName = 'Carousel';
47
+ static defaultProps = {
48
+ defaultIndex: 0,
49
+ duration: 350,
50
+ step: 100,
51
+ bounded: false,
52
+ i18n: localizedMessages,
53
+ locale: 'en',
54
+ indicators: 'default',
55
+ };
56
+
57
+ static style = style;
58
+ static enhance = Object.values(enhance);
59
+
60
+ defaultItemsCount = 0;
61
+ refContainer = React.createRef<HTMLElement>();
62
+ refModalContainer = React.createRef<HTMLElement>();
63
+ _touchStartCoord = -1;
64
+
65
+ constructor(props: CarouselProps) {
66
+ super(props);
67
+ this.isControlled = props.index !== undefined;
68
+ this.state = {
69
+ items: [],
70
+ isOpenZoom: false,
71
+ selectedIndex: props.index ?? props.defaultIndex ?? 0,
72
+ };
73
+ }
74
+
75
+ uncontrolledProps() {
76
+ return {
77
+ index: null,
78
+ };
79
+ }
80
+
81
+ componentDidMount() {
82
+ const { selectedIndex } = this.state;
83
+
84
+ if (selectedIndex !== 0) {
85
+ if (selectedIndex < 0 || selectedIndex >= this.defaultItemsCount) {
86
+ logger.warn(
87
+ true,
88
+ `You couldn't use value for the \`index\` or \`defaultIndex\` not from \`Item's\` length range.`,
89
+ CarouselRoot.displayName,
90
+ );
91
+ this.setState({ selectedIndex: 0 });
92
+ } else {
93
+ this.transformContainer();
94
+ }
95
+ }
96
+
97
+ const deprecatedComponents = findAllComponents(this.asProps.Children, [
98
+ 'Carousel.Prev',
99
+ 'Carousel.Next',
100
+ 'Carousel.Indicators',
101
+ ]);
102
+
103
+ logger.warn(
104
+ deprecatedComponents.length > 0,
105
+ 'Please, try to remove `Prev`, `Next`, `Indicators` and other children components from your Carousel, except only `Item` elements.',
106
+ CarouselRoot.displayName,
107
+ );
108
+ }
109
+
110
+ componentDidUpdate(prevProps: CarouselProps) {
111
+ const { index } = this.asProps;
112
+ if (prevProps.index !== index && this.isControlled && index !== undefined) {
113
+ this.setState({ selectedIndex: index }, () => this.transformContainer());
114
+ }
115
+ }
116
+
117
+ handlerKeyDown = (e: React.KeyboardEvent) => {
118
+ switch (e.key) {
119
+ case 'ArrowLeft':
120
+ case 'ArrowRight': {
121
+ e.preventDefault();
122
+ this.transformItem(MAP_TRANSFORM[e.key]);
123
+ }
124
+ }
125
+ };
126
+
127
+ toggleItem = (item: CarouselItem, removeItem = false) => {
128
+ this.setState((prevState) => {
129
+ const newItems = removeItem
130
+ ? prevState.items.filter((element) => element.node !== item.node)
131
+ : [...prevState.items, item];
132
+
133
+ return {
134
+ items: newItems,
135
+ };
136
+ });
137
+
138
+ if (!removeItem) {
139
+ this.defaultItemsCount++;
140
+ }
141
+ };
142
+
143
+ transformContainer = () => {
144
+ const transform = this.state.selectedIndex * -1 * 100;
145
+
146
+ if (this.refContainer.current) {
147
+ this.refContainer.current.style.transform = `translateX(${transform}%)`;
148
+ }
149
+ if (this.refModalContainer.current) {
150
+ this.refModalContainer.current.style.transform = `translateX(${transform}%)`;
151
+ }
152
+ };
153
+
154
+ getDirection = (currentIndex: number, nextIndex: number) => {
155
+ const { bounded } = this.asProps;
156
+
157
+ if (bounded) {
158
+ return currentIndex < nextIndex ? 'right' : 'left';
159
+ }
160
+ const { items } = this.state;
161
+ const listIndex = items.map((_, ind) => ind);
162
+ const tmpArr = [...listIndex];
163
+ const minTmpArr = tmpArr[0];
164
+ const maxTmpArr = tmpArr[tmpArr.length - 1];
165
+
166
+ if (tmpArr.length === 2) {
167
+ return currentIndex < nextIndex ? 'right' : 'left';
168
+ }
169
+ if (currentIndex === minTmpArr) {
170
+ tmpArr.unshift(maxTmpArr);
171
+ tmpArr.pop();
172
+ }
173
+ if (currentIndex === maxTmpArr) {
174
+ tmpArr.shift();
175
+ tmpArr.push(minTmpArr);
176
+ }
177
+
178
+ const tmpCurrentIndex = tmpArr.indexOf(currentIndex);
179
+ const left = tmpArr.indexOf(nextIndex);
180
+
181
+ return left - tmpCurrentIndex < 0 ? 'left' : 'right';
182
+ };
183
+
184
+ slideToValue = (nextIndex: number) => {
185
+ const { selectedIndex } = this.state;
186
+ const direction = selectedIndex < nextIndex ? 'right' : 'left';
187
+ let diff = Math.abs(selectedIndex - nextIndex);
188
+ while (diff > 0) {
189
+ this.transformItem(direction);
190
+ diff--;
191
+ }
192
+ };
193
+
194
+ transformItem = (direction: 'left' | 'right') => {
195
+ const { bounded } = this.asProps;
196
+ const { items, selectedIndex } = this.state;
197
+ const maxIndexIndicator = items.length - 1;
198
+
199
+ if (direction === 'right') {
200
+ if (bounded && selectedIndex === maxIndexIndicator) {
201
+ this.handlers.index(maxIndexIndicator);
202
+
203
+ return;
204
+ }
205
+ if (this.isControlled) {
206
+ this.handlers.index(selectedIndex === maxIndexIndicator ? 0 : selectedIndex + 1);
207
+
208
+ return;
209
+ }
210
+
211
+ this.setState(
212
+ (prevState) => ({
213
+ selectedIndex: prevState.selectedIndex + 1,
214
+ }),
215
+ () => {
216
+ this.transformContainer();
217
+ this.handlers.index(this.state.selectedIndex);
218
+ },
219
+ );
220
+
221
+ return;
222
+ }
223
+ if (direction === 'left') {
224
+ if (bounded && selectedIndex === 0) {
225
+ this.handlers.index(0);
226
+
227
+ return;
228
+ }
229
+ if (this.isControlled) {
230
+ this.handlers.index(selectedIndex === 0 ? maxIndexIndicator : selectedIndex - 1);
231
+
232
+ return;
233
+ }
234
+
235
+ this.setState(
236
+ (prevState) => ({
237
+ selectedIndex: prevState.selectedIndex - 1,
238
+ }),
239
+ () => {
240
+ this.transformContainer();
241
+ this.handlers.index(this.state.selectedIndex);
242
+ },
243
+ );
244
+
245
+ return;
246
+ }
247
+ };
248
+
249
+ bindHandlerClick = (direction: 'left' | 'right') => {
250
+ return () => {
251
+ this.transformItem(direction);
252
+ };
253
+ };
254
+
255
+ bindHandlerClickIndicator = (value: number) => {
256
+ return () => {
257
+ const { selectedIndex, items } = this.state;
258
+ if (!this.isControlled && value !== selectedIndex) {
259
+ const newValueIndex = Math.floor(selectedIndex / items.length) * items.length + value;
260
+ this.slideToValue(newValueIndex);
261
+ }
262
+ this.handlers.index(value);
263
+ };
264
+ };
265
+
266
+ handlerTouchStart = (e: React.TouchEvent) => {
267
+ this._touchStartCoord = e.changedTouches[0].clientX;
268
+ };
269
+
270
+ handlerTouchEnd = (e: React.TouchEvent) => {
271
+ const touchEndCoord = e.changedTouches[0].clientX;
272
+ const delta = touchEndCoord - this._touchStartCoord;
273
+ if (delta > 50) {
274
+ this.transformItem('left');
275
+ } else if (delta < -50) {
276
+ this.transformItem('right');
277
+ }
278
+ };
279
+
280
+ getContainerProps() {
281
+ const { duration } = this.asProps;
282
+
283
+ return {
284
+ ref: this.refContainer,
285
+ duration,
286
+ };
287
+ }
288
+
289
+ getItemProps(props: CarouselItemProps, index: number) {
290
+ const { zoom } = this.asProps;
291
+ const isCurrent = this.isSelected(index);
292
+
293
+ return {
294
+ toggleItem: this.toggleItem,
295
+ uid: this.asProps.uid,
296
+ index,
297
+ current: isCurrent,
298
+ zoomIn: zoom,
299
+ onToggleZoomModal: this.handleToggleZoomModal,
300
+ transform: isCurrent ? this.getTransform() : undefined,
301
+ };
302
+ }
303
+
304
+ handleToggleZoomModal = () => {
305
+ this.setState(
306
+ (prevState) => {
307
+ return {
308
+ isOpenZoom: !prevState.isOpenZoom,
309
+ };
310
+ },
311
+ () => {
312
+ this.state.isOpenZoom && this.transformContainer();
313
+ },
314
+ );
315
+ };
316
+
317
+ bindHandlerKeydownControl = (direction: 'left' | 'right') => (e: React.KeyboardEvent) => {
318
+ const { key } = e;
319
+ if (key === 'Enter') {
320
+ e.preventDefault();
321
+ this.bindHandlerClick(direction)();
322
+ }
323
+ };
324
+
325
+ getPrevProps() {
326
+ const { bounded, getI18nText } = this.asProps;
327
+ const { items, selectedIndex } = this.state;
328
+ let disabled = false;
329
+ if (items.length && bounded) {
330
+ disabled = selectedIndex === 0;
331
+ }
332
+ return {
333
+ onClick: this.bindHandlerClick('left'),
334
+ onKeyDown: this.bindHandlerKeydownControl('left'),
335
+ disabled,
336
+ label: getI18nText('prev'),
337
+ };
338
+ }
339
+
340
+ getNextProps() {
341
+ const { bounded, getI18nText } = this.asProps;
342
+ const { items, selectedIndex } = this.state;
343
+ let disabled = false;
344
+ if (items.length && bounded) {
345
+ disabled = selectedIndex === items.length - 1;
346
+ }
347
+
348
+ return {
349
+ onClick: this.bindHandlerClick('right'),
350
+ onKeyDown: this.bindHandlerKeydownControl('right'),
351
+ disabled,
352
+ label: getI18nText('next'),
353
+ };
354
+ }
355
+
356
+ getIndicatorsProps() {
357
+ const { items } = this.state;
358
+
359
+ return {
360
+ items: items.map((item, key) => ({
361
+ active: this.isSelected(key),
362
+ onClick: this.bindHandlerClickIndicator(key),
363
+ key,
364
+ })),
365
+ };
366
+ }
367
+
368
+ getTransform() {
369
+ const { items, selectedIndex } = this.state;
370
+
371
+ const direction = selectedIndex > 0 ? 1 : -1;
372
+ const count = items.length === 0 ? 0 : Math.floor(selectedIndex / items.length) * direction;
373
+ const transform =
374
+ selectedIndex > 0 && selectedIndex < items.length
375
+ ? 0
376
+ : 100 * direction * count * items.length;
377
+
378
+ return transform;
379
+ }
380
+
381
+ isSelected(index: number) {
382
+ const { items, selectedIndex } = this.state;
383
+
384
+ if (items.length === 0) {
385
+ return true;
386
+ }
387
+
388
+ if (selectedIndex >= 0) {
389
+ return index === selectedIndex % items.length;
390
+ }
391
+
392
+ return index === (items.length + (selectedIndex % items.length)) % items.length;
393
+ }
394
+
395
+ renderModal(isSmall: boolean, ComponentItems: any[]) {
396
+ const SModalContainer = Root;
397
+ const {
398
+ styles,
399
+ uid,
400
+ duration,
401
+ zoom: hasZoom,
402
+ zoomWidth,
403
+ 'aria-label': ariaLabel,
404
+ 'aria-roledescription': ariaRoledescription,
405
+ } = this.asProps;
406
+ const { isOpenZoom } = this.state;
407
+
408
+ return sstyled(styles)(
409
+ <Modal
410
+ visible={isOpenZoom}
411
+ onClose={this.handleToggleZoomModal}
412
+ ghost={true}
413
+ closable={!isSmall}
414
+ >
415
+ <Flex direction={isSmall ? 'column' : 'row'}>
416
+ {!isSmall && <Carousel.Prev inverted={true} />}
417
+ <Box style={{ overflow: 'hidden', borderRadius: 6 }}>
418
+ <SModalContainer
419
+ render={Box}
420
+ role='list'
421
+ use:duration={`${duration}ms`}
422
+ ref={this.refModalContainer}
423
+ use:w={undefined}
424
+ wMax={zoomWidth}
425
+ >
426
+ {ComponentItems.map((item, i) => {
427
+ return (
428
+ <Carousel.Item
429
+ {...item.props}
430
+ key={item.key}
431
+ uid={uid}
432
+ index={i}
433
+ current={this.isSelected(i)}
434
+ toggleItem={undefined}
435
+ zoom={true}
436
+ zoomOut={true}
437
+ transform={this.isSelected(i) ? this.getTransform() : undefined}
438
+ />
439
+ );
440
+ })}
441
+ </SModalContainer>
442
+ </Box>
443
+ {isSmall ? (
444
+ <Flex justifyContent={'center'} mt={2}>
445
+ <Carousel.Prev inverted={true} />
446
+ <Carousel.Next inverted={true} />
447
+ </Flex>
448
+ ) : (
449
+ <Carousel.Next inverted={true} />
450
+ )}
451
+ </Flex>
452
+ {!isSmall && <Carousel.Indicators inverted={true} />}
453
+ </Modal>,
454
+ );
455
+ }
456
+
457
+ render() {
458
+ const SCarousel = Root;
459
+ const {
460
+ styles,
461
+ Children,
462
+ zoom: hasZoom,
463
+ 'aria-label': ariaLabel,
464
+ 'aria-roledescription': ariaRoledescription,
465
+ indicators,
466
+ } = this.asProps;
467
+ const ComponentItems = findAllComponents(Children, ['Carousel.Item']);
468
+ const Controls = findAllComponents(Children, [
469
+ 'Carousel.Prev',
470
+ 'Carousel.Next',
471
+ 'Carousel.Indicators',
472
+ ]);
473
+
474
+ return sstyled(styles)(
475
+ <SCarousel
476
+ render={Box}
477
+ role='group'
478
+ onKeyDown={this.handlerKeyDown}
479
+ tabIndex={0}
480
+ onTouchStart={this.handlerTouchStart}
481
+ onTouchEnd={this.handlerTouchEnd}
482
+ >
483
+ {Controls.length === 0 ? (
484
+ <>
485
+ <Flex>
486
+ <Carousel.Prev />
487
+ <Box style={{ overflow: 'hidden', borderRadius: 6 }}>
488
+ <Carousel.Container
489
+ aria-roledescription={ariaRoledescription}
490
+ aria-label={ariaLabel}
491
+ >
492
+ <Children />
493
+ </Carousel.Container>
494
+ </Box>
495
+ <Carousel.Next />
496
+ </Flex>
497
+ {indicators === 'default' && <Carousel.Indicators />}
498
+ {indicators === 'preview' && (
499
+ <Carousel.Indicators>
500
+ {() =>
501
+ ComponentItems.map((item, index) => (
502
+ <Carousel.Indicator
503
+ {...item.props}
504
+ key={item.key}
505
+ w={100}
506
+ h={100}
507
+ aria-roledescription='slide'
508
+ active={this.isSelected(index)}
509
+ onClick={this.bindHandlerClickIndicator(index)}
510
+ />
511
+ ))
512
+ }
513
+ </Carousel.Indicators>
514
+ )}
515
+ </>
516
+ ) : (
517
+ <Children />
518
+ )}
519
+ {hasZoom && (
520
+ <BreakPoints>
521
+ <BreakPoints.Context.Consumer>
522
+ {(mediaIndex) => this.renderModal(isSmallScreen(mediaIndex), ComponentItems)}
523
+ </BreakPoints.Context.Consumer>
524
+ </BreakPoints>
525
+ )}
526
+ </SCarousel>,
527
+ );
528
+ }
529
+ }
530
+
531
+ const Container = (props: BoxProps & { duration?: number }) => {
532
+ const SContainer = Root;
533
+ const { styles, duration } = props;
534
+
535
+ return sstyled(styles)(<SContainer render={Box} role='list' use:duration={`${duration}ms`} />);
536
+ };
537
+
538
+ class Item extends Component<CarouselItemProps> {
539
+ refItem = React.createRef<HTMLElement>();
540
+
541
+ componentDidMount() {
542
+ const { toggleItem } = this.props;
543
+ const refItem = this.refItem.current;
544
+
545
+ toggleItem && refItem && toggleItem({ node: refItem });
546
+ }
547
+
548
+ componentWillUnmount() {
549
+ const { toggleItem } = this.props;
550
+ const refItem = this.refItem.current;
551
+
552
+ toggleItem && refItem && toggleItem({ node: refItem }, true);
553
+ }
554
+
555
+ componentDidUpdate(prevProps: CarouselItemProps) {
556
+ const transform = this.props.transform;
557
+ const refItem = this.refItem.current;
558
+
559
+ if (prevProps.transform !== transform && refItem) {
560
+ refItem.style.transform = `translateX(${transform}%)`;
561
+ }
562
+ }
563
+
564
+ render() {
565
+ const { styles, index, uid, current, zoomIn, onToggleZoomModal, transform } = this.props;
566
+ const SItem = Root;
567
+
568
+ return sstyled(styles)(
569
+ <SItem
570
+ render={Box}
571
+ ref={this.refItem}
572
+ role='listitem'
573
+ id={`igc-${uid}-carousel-item-${index}`}
574
+ aria-current={current}
575
+ onClick={zoomIn ? onToggleZoomModal : undefined}
576
+ zoomIn={zoomIn}
577
+ />,
578
+ );
579
+ }
580
+ }
581
+
582
+ const Prev = (props: CarouselButtonProps) => {
583
+ const { styles, children, Children, label, top = 0, inverted } = props;
584
+ const SPrev = Root;
585
+ const [isActive, setIsActive] = React.useState(false);
586
+ const handleMouseEnter = React.useCallback(() => {
587
+ setIsActive(true);
588
+ }, []);
589
+ const handleMouseLeave = React.useCallback(() => {
590
+ setIsActive(false);
591
+ }, []);
592
+
593
+ return sstyled(styles)(
594
+ <SPrev render={Box} top={top} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
595
+ {children ? (
596
+ <Children />
597
+ ) : (
598
+ <Button
599
+ addonLeft={ChevronLeft}
600
+ aria-label={label}
601
+ theme={inverted ? 'invert' : 'muted'}
602
+ use={'tertiary'}
603
+ active={isActive}
604
+ size={'l'}
605
+ />
606
+ )}
607
+ </SPrev>,
608
+ );
609
+ };
610
+
611
+ const Next = (props: CarouselButtonProps) => {
612
+ const { styles, children, Children, label, top = 0, inverted } = props;
613
+ const SNext = Root;
614
+ const [isActive, setIsActive] = React.useState(false);
615
+ const handleMouseEnter = React.useCallback(() => {
616
+ setIsActive(true);
617
+ }, []);
618
+ const handleMouseLeave = React.useCallback(() => {
619
+ setIsActive(false);
620
+ }, []);
621
+
622
+ return sstyled(styles)(
623
+ <SNext render={Box} top={top} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
624
+ {children ? (
625
+ <Children />
626
+ ) : (
627
+ <Button
628
+ addonLeft={ChevronRight}
629
+ aria-label={label}
630
+ theme={inverted ? 'invert' : 'muted'}
631
+ use={'tertiary'}
632
+ active={isActive}
633
+ size={'l'}
634
+ />
635
+ )}
636
+ </SNext>,
637
+ );
638
+ };
639
+
640
+ const Indicators = ({ items, styles, Children, inverted }: CarouselIndicatorsProps) => {
641
+ const SIndicators = Root;
642
+ if (Children.origin) {
643
+ return sstyled(styles)(
644
+ <SIndicators render={Box} aria-hidden='true'>
645
+ <Children />
646
+ </SIndicators>,
647
+ );
648
+ }
649
+ return sstyled(styles)(
650
+ <SIndicators render={Box} aria-hidden='true'>
651
+ {items?.map((item, index) => (
652
+ <Carousel.Indicator key={index} {...item} inverted={inverted} />
653
+ ))}
654
+ </SIndicators>,
655
+ );
656
+ };
657
+
658
+ const Indicator = ({ styles, Children }: CarouselIndicatorProps) => {
659
+ const SIndicator = Root;
660
+ return sstyled(styles)(
661
+ <SIndicator render={Box}>
662
+ <Children />
663
+ </SIndicator>,
664
+ );
665
+ };
666
+
667
+ const Carousel: typeof CarouselType = createComponent(CarouselRoot, {
668
+ Container,
669
+ Indicators,
670
+ Indicator,
671
+ Item,
672
+ Prev,
673
+ Next,
674
+ });
675
+
676
+ export default Carousel;