@transferwise/components 46.31.0 → 46.32.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,345 @@
1
+ import { ChevronLeft, ChevronRight } from '@transferwise/icons';
2
+ import classNames from 'classnames';
3
+ import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react';
4
+
5
+ import ActionButton from '../actionButton';
6
+ import Title from '../title';
7
+ import type { PromoCardLinkProps } from '../promoCard/PromoCard';
8
+ import PromoCard from '../promoCard/PromoCard';
9
+
10
+ export type CarouselCardCommon = {
11
+ id: string;
12
+ href?: string;
13
+ onClick?: () => void;
14
+ className?: string;
15
+ styles?: CSSProperties;
16
+ };
17
+
18
+ export type CarouselDefaultCard = CarouselCardCommon & {
19
+ type: 'anchor' | 'button';
20
+ content: ReactNode;
21
+ };
22
+
23
+ export type CarouselPromoCard = CarouselCardCommon & {
24
+ type: 'promo';
25
+ } & Omit<PromoCardLinkProps, 'type'>;
26
+
27
+ export type CarouselCard = CarouselDefaultCard | CarouselPromoCard;
28
+ export interface CarouselProps {
29
+ header: string | ReactNode;
30
+ className?: string;
31
+ cards: CarouselCard[];
32
+ onClick?: (cardId: string) => void;
33
+ }
34
+
35
+ type CardsReference = {
36
+ type: 'promo' | 'default';
37
+ cardElement: HTMLDivElement | HTMLAnchorElement;
38
+ anchorElement?: HTMLAnchorElement;
39
+ };
40
+
41
+ const LEFT_SCROLL_OFFSET = 8;
42
+
43
+ const Carousel: React.FC<CarouselProps> = ({ header, className, cards, onClick }) => {
44
+ const [scrollPosition, setScrollPosition] = useState(0);
45
+ const [previousScrollPosition, setPreviousScrollPosition] = useState(0);
46
+ const [scrollIsAtEnd, setScrollIsAtEnd] = useState(false);
47
+ const [visibleCardOnMobileView, setVisibleCardOnMobileView] = useState<string>('');
48
+ const carouselElementRef = useRef<HTMLDivElement>(null);
49
+ const carouselCardsRef = useRef<CardsReference[]>([]);
50
+
51
+ const isLeftActionButtonEnabled = scrollPosition > LEFT_SCROLL_OFFSET;
52
+
53
+ const areActionButtonsEnabled = isLeftActionButtonEnabled || !scrollIsAtEnd;
54
+
55
+ const [focusedCard, setFocusedCard] = useState(cards?.[0]?.id);
56
+
57
+ const updateScrollButtonsState = () => {
58
+ if (carouselElementRef.current) {
59
+ const { scrollWidth, offsetWidth } = carouselElementRef.current;
60
+
61
+ const scrollAtEnd = scrollWidth - offsetWidth <= scrollPosition + LEFT_SCROLL_OFFSET;
62
+ setScrollIsAtEnd(scrollAtEnd);
63
+ }
64
+
65
+ const scrollDirecton = scrollPosition > previousScrollPosition ? 'right' : 'left';
66
+
67
+ const cardsInFullViewIds: string[] = [];
68
+ carouselCardsRef.current.forEach((card) => {
69
+ if (isVisible(carouselElementRef.current as HTMLElement, card.cardElement as HTMLElement)) {
70
+ // eslint-disable-next-line functional/immutable-data
71
+ cardsInFullViewIds.push(card.cardElement.getAttribute('id') ?? '');
72
+ }
73
+ });
74
+
75
+ if (cardsInFullViewIds.length >= 1) {
76
+ const visibleCardIndex = scrollDirecton === 'right' ? cardsInFullViewIds.length - 1 : 0;
77
+ const visibleCardId = cardsInFullViewIds[visibleCardIndex];
78
+ setVisibleCardOnMobileView(visibleCardId);
79
+ setFocusedCard(visibleCardId);
80
+ }
81
+
82
+ setPreviousScrollPosition(scrollPosition);
83
+ };
84
+
85
+ const scrollCarousel = (direction: 'left' | 'right' = 'right') => {
86
+ if (carouselElementRef.current) {
87
+ const { scrollWidth } = carouselElementRef.current;
88
+
89
+ const cardWidth = scrollWidth / carouselCardsRef.current.length;
90
+
91
+ const res = Math.floor(cardWidth - cardWidth * 0.05);
92
+
93
+ carouselElementRef.current.scrollBy({
94
+ left: direction === 'right' ? res : -res,
95
+ behavior: 'smooth',
96
+ });
97
+ }
98
+ };
99
+
100
+ const handleOnKeyDown = (
101
+ event: React.KeyboardEvent<HTMLAnchorElement | HTMLDivElement>,
102
+ index: number,
103
+ ) => {
104
+ if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
105
+ const nextIndex = event.key === 'ArrowRight' ? index + 1 : index - 1;
106
+ const nextCard = cards[nextIndex];
107
+ if (nextCard) {
108
+ const ref = carouselCardsRef.current[nextIndex];
109
+ if (ref.type === 'promo') {
110
+ ref.anchorElement?.focus();
111
+ } else {
112
+ ref.cardElement?.focus();
113
+ }
114
+
115
+ scrollCardIntoView(carouselCardsRef.current[nextIndex].cardElement, nextCard);
116
+ event.preventDefault();
117
+ }
118
+ }
119
+
120
+ if (event.key === 'Enter' || event.key === ' ') {
121
+ event.currentTarget.click();
122
+ }
123
+ };
124
+
125
+ const scrollCardIntoView = (element: HTMLElement, card: CarouselCard) => {
126
+ element.scrollIntoView({
127
+ behavior: 'smooth',
128
+ block: 'nearest',
129
+ inline: 'center',
130
+ });
131
+
132
+ setFocusedCard(card.id);
133
+ };
134
+
135
+ useEffect(() => {
136
+ updateScrollButtonsState();
137
+ // eslint-disable-next-line react-hooks/exhaustive-deps
138
+ }, [scrollPosition]);
139
+
140
+ useEffect(() => {
141
+ window.addEventListener('resize', updateScrollButtonsState);
142
+
143
+ return () => {
144
+ window.removeEventListener('resize', updateScrollButtonsState);
145
+ };
146
+ // eslint-disable-next-line react-hooks/exhaustive-deps
147
+ }, []);
148
+
149
+ const addElementToCardsRefArray = (index: number, ref: Partial<CardsReference>) => {
150
+ if (ref) {
151
+ // eslint-disable-next-line functional/immutable-data
152
+ carouselCardsRef.current[index] = {
153
+ type: ref.type ?? carouselCardsRef.current?.[index]?.type,
154
+ cardElement: ref.cardElement ?? carouselCardsRef.current?.[index]?.cardElement,
155
+ anchorElement: ref.anchorElement ?? carouselCardsRef.current?.[index]?.anchorElement,
156
+ };
157
+ }
158
+ };
159
+
160
+ return (
161
+ <div className={classNames('carousel-wrapper', className)}>
162
+ <div className="d-flex justify-content-between carousel__header">
163
+ {typeof header === 'string' ? (
164
+ <Title as="span" type="title-body">
165
+ {header}
166
+ </Title>
167
+ ) : (
168
+ header
169
+ )}
170
+ {areActionButtonsEnabled ? (
171
+ <div className="hidden-xs">
172
+ <ActionButton
173
+ className="carousel__scroll-button"
174
+ tabIndex={-1}
175
+ priority="secondary"
176
+ disabled={!isLeftActionButtonEnabled}
177
+ aria-hidden="true"
178
+ data-testid="scroll-carousel-left"
179
+ onClick={() => scrollCarousel('left')}
180
+ >
181
+ <ChevronLeft />
182
+ </ActionButton>
183
+ <ActionButton
184
+ tabIndex={-1}
185
+ className="carousel__scroll-button m-l-1"
186
+ priority="secondary"
187
+ aria-hidden="true"
188
+ data-testid="scroll-carousel-right"
189
+ disabled={scrollIsAtEnd}
190
+ onClick={() => scrollCarousel()}
191
+ >
192
+ <ChevronRight />
193
+ </ActionButton>
194
+ </div>
195
+ ) : null}
196
+ </div>
197
+ <div
198
+ ref={carouselElementRef}
199
+ tabIndex={-1}
200
+ role="list"
201
+ className="carousel"
202
+ onScroll={(event) => {
203
+ const target = event.target as HTMLElement;
204
+ setScrollPosition(target.scrollLeft);
205
+ }}
206
+ >
207
+ {cards?.map((card, index) => {
208
+ const sharedProps = {
209
+ id: card.id,
210
+ className: classNames('carousel__card', {
211
+ 'carousel__card--focused': card.id === focusedCard,
212
+ }),
213
+ onClick: () => {
214
+ card.onClick?.();
215
+ onClick?.(card.id);
216
+ },
217
+ onFocus: (event: React.FocusEvent<HTMLAnchorElement | HTMLDivElement>) => {
218
+ scrollCardIntoView(event.currentTarget, card);
219
+ },
220
+ };
221
+
222
+ const cardContent =
223
+ card.type !== 'promo' ? (
224
+ <div
225
+ id={`${card.id}-content`}
226
+ className={classNames('carousel__card-content', {
227
+ [card.className ?? '']: !!card.className,
228
+ })}
229
+ // eslint-disable-next-line react/forbid-dom-props
230
+ style={card.styles}
231
+ >
232
+ {card.content}
233
+ </div>
234
+ ) : null;
235
+
236
+ if (card.type === 'button') {
237
+ return (
238
+ <div key={card.id} aria-labelledby={`${card.id}-content`} role="listitem">
239
+ <div
240
+ {...sharedProps}
241
+ ref={(el) => {
242
+ if (el) {
243
+ // eslint-disable-next-line functional/immutable-data
244
+ carouselCardsRef.current[index] = {
245
+ type: 'default',
246
+ cardElement: el,
247
+ };
248
+ }
249
+ }}
250
+ role="button"
251
+ tabIndex={0}
252
+ onKeyDown={(event) => handleOnKeyDown(event, index)}
253
+ >
254
+ {cardContent}
255
+ </div>
256
+ </div>
257
+ );
258
+ }
259
+
260
+ if (card.type === 'promo') {
261
+ return (
262
+ <div key={card.id} id={card.id} role="listitem" aria-labelledby={`${card.id}-anchor`}>
263
+ <PromoCard
264
+ {...{ ...card, type: undefined }}
265
+ {...{ ...sharedProps }}
266
+ ref={(el: HTMLDivElement | null) => {
267
+ if (el) {
268
+ addElementToCardsRefArray(index, {
269
+ type: 'promo',
270
+ cardElement: el,
271
+ });
272
+ }
273
+ }}
274
+ anchorRef={(el: HTMLAnchorElement) => {
275
+ if (el) {
276
+ addElementToCardsRefArray(index, {
277
+ type: 'promo',
278
+ anchorElement: el,
279
+ });
280
+ }
281
+ }}
282
+ anchorId={`${card.id}-anchor`}
283
+ onKeyDown={(event) => handleOnKeyDown(event, index)}
284
+ />
285
+ </div>
286
+ );
287
+ }
288
+
289
+ return (
290
+ <div key={card.id} aria-labelledby={`${card.id}-content`} role="listitem">
291
+ <a
292
+ {...sharedProps}
293
+ ref={(el) => {
294
+ if (el) {
295
+ // eslint-disable-next-line functional/immutable-data
296
+ carouselCardsRef.current[index] = {
297
+ type: 'default',
298
+ cardElement: el,
299
+ };
300
+ }
301
+ }}
302
+ href={card.href}
303
+ rel="noreferrer"
304
+ onKeyDown={(event) => handleOnKeyDown(event, index)}
305
+ >
306
+ {cardContent}
307
+ </a>
308
+ </div>
309
+ );
310
+ })}
311
+ </div>
312
+ <div className="visible-xs">
313
+ <div className="carousel__indicators">
314
+ {cards?.map((card, index) => (
315
+ <button
316
+ key={`${card.id}-indicator`}
317
+ data-testid={`${card.id}-indicator`}
318
+ tabIndex={-1}
319
+ aria-hidden
320
+ type="button"
321
+ className={classNames('carousel__indicator', {
322
+ 'carousel__indicator--selected': card.id === visibleCardOnMobileView,
323
+ })}
324
+ onClick={() => {
325
+ scrollCardIntoView(carouselCardsRef.current[index].cardElement, card);
326
+ }}
327
+ />
328
+ ))}
329
+ </div>
330
+ </div>
331
+ </div>
332
+ );
333
+ };
334
+
335
+ const isVisible = (container: HTMLElement, el: HTMLElement) => {
336
+ const cWidth = container.offsetWidth;
337
+ const cScrollOffset = container.scrollLeft;
338
+
339
+ const elemLeft = el.offsetLeft - container.offsetLeft;
340
+ const elemRight = elemLeft + el.offsetWidth;
341
+
342
+ return elemLeft >= cScrollOffset && elemRight <= cScrollOffset + cWidth;
343
+ };
344
+
345
+ export default Carousel;
@@ -0,0 +1,3 @@
1
+ export * from './Carousel';
2
+
3
+ export { default } from './Carousel';
@@ -1,5 +1,5 @@
1
1
  import classNames from 'classnames';
2
- import { MouseEvent, ReactNode, useRef } from 'react';
2
+ import { MouseEvent, type ReactNode, forwardRef, useRef } from 'react';
3
3
 
4
4
  import { CloseButton } from '../closeButton';
5
5
  import { stopPropagation } from '../domHelpers';
@@ -48,48 +48,56 @@ export interface CardProps {
48
48
  * <p>Hello World!</p>
49
49
  * </Card>
50
50
  */
51
- const Card: React.FC<CardProps> = ({
52
- className,
53
- children = null,
54
- id,
55
- isDisabled = false,
56
- isSmall = false,
57
- onDismiss,
58
- testId,
59
- ...props
60
- }) => {
61
- const closeButtonReference = useRef(null);
51
+ const Card = forwardRef<HTMLDivElement, CardProps>(
52
+ (
53
+ {
54
+ className,
55
+ children = null,
56
+ id,
57
+ isDisabled = false,
58
+ isSmall = false,
59
+ onDismiss,
60
+ testId,
61
+ ...props
62
+ },
63
+ ref,
64
+ ) => {
65
+ const closeButtonReference = useRef(null);
62
66
 
63
- return (
64
- <div
65
- className={classNames(
66
- 'np-Card',
67
- {
68
- 'np-Card--small': !!isSmall,
69
- 'is-disabled': !!isDisabled,
70
- },
71
- className,
72
- )}
73
- id={id}
74
- data-testid={testId}
75
- {...props}
76
- >
77
- {onDismiss && (
78
- <CloseButton
79
- ref={closeButtonReference}
80
- className="np-Card-closeButton"
81
- size={isSmall ? 'sm' : 'md'}
82
- isDisabled={isDisabled}
83
- testId="close-button"
84
- onClick={(e) => {
85
- stopPropagation(e);
86
- onDismiss();
87
- }}
88
- />
89
- )}
90
- {children}
91
- </div>
92
- );
93
- };
67
+ return (
68
+ <div
69
+ ref={ref}
70
+ className={classNames(
71
+ 'np-Card',
72
+ {
73
+ 'np-Card--small': !!isSmall,
74
+ 'is-disabled': !!isDisabled,
75
+ },
76
+ className,
77
+ )}
78
+ id={id}
79
+ data-testid={testId}
80
+ {...props}
81
+ >
82
+ {onDismiss && (
83
+ <CloseButton
84
+ ref={closeButtonReference}
85
+ className="np-Card-closeButton"
86
+ size={isSmall ? 'sm' : 'md'}
87
+ isDisabled={isDisabled}
88
+ testId="close-button"
89
+ onClick={(e) => {
90
+ stopPropagation(e);
91
+ onDismiss();
92
+ }}
93
+ />
94
+ )}
95
+ {children}
96
+ </div>
97
+ );
98
+ },
99
+ );
100
+
101
+ Card.displayName = 'Card';
94
102
 
95
103
  export default Card;
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export type { ActionOptionProps } from './actionOption';
6
6
  export type { AlertAction, AlertProps, AlertType } from './alert';
7
7
  export type { AvatarProps } from './avatar';
8
8
  export type { BadgeProps } from './badge';
9
+ export type { CarouselProps } from './carousel';
9
10
  export type { CircularButtonProps } from './circularButton';
10
11
  export type {
11
12
  BodyTypes,
@@ -84,6 +85,7 @@ export { default as AvatarWrapper } from './avatarWrapper';
84
85
  export { default as Badge } from './badge';
85
86
  export { default as Body } from './body';
86
87
  export { default as Button } from './button';
88
+ export { default as Carousel } from './carousel';
87
89
  export { default as Card } from './card';
88
90
  export { default as Checkbox } from './checkbox';
89
91
  export { default as CheckboxButton } from './checkboxButton';
package/src/main.css CHANGED
@@ -643,6 +643,141 @@ div.critical-comms .critical-comms-body {
643
643
  border-radius: 16px 16px 0 0;
644
644
  border-radius: var(--radius-medium) var(--radius-medium) 0 0;
645
645
  }
646
+ .carousel-wrapper {
647
+ overflow: hidden;
648
+ }
649
+ .carousel {
650
+ display: flex;
651
+ align-items: center;
652
+ overflow-x: scroll;
653
+ overflow-y: hidden;
654
+ scroll-snap-type: x mandatory;
655
+ scroll-behavior: smooth;
656
+ gap: 16px;
657
+ gap: var(--size-16);
658
+ padding: 8px;
659
+ padding: var(--size-8);
660
+ margin: 8px;
661
+ margin: var(--size-8);
662
+ }
663
+ @media (max-width: 767px) {
664
+ .carousel {
665
+ gap: 8px;
666
+ gap: var(--size-8);
667
+ }
668
+ }
669
+ .carousel__header {
670
+ display: flex;
671
+ align-items: center;
672
+ overflow: hidden;
673
+ min-height: 32px;
674
+ min-height: var(--size-32);
675
+ padding-bottom: 16px;
676
+ padding-bottom: var(--size-16);
677
+ }
678
+ .carousel__card,
679
+ .carousel__card:hover,
680
+ .carousel__card:focus,
681
+ .carousel__card:focus-within {
682
+ -webkit-text-decoration: none;
683
+ text-decoration: none;
684
+ transition: none !important;
685
+ box-shadow: none !important;
686
+ }
687
+ .carousel__card {
688
+ display: block;
689
+ position: relative;
690
+ text-align: left;
691
+ border: none;
692
+ overflow: hidden;
693
+ background: rgba(134,167,189,0.10196);
694
+ background: var(--color-background-neutral);
695
+ border-radius: 32px;
696
+ border-radius: var(--size-32);
697
+ scroll-snap-align: center;
698
+ -webkit-scroll-snap-align: center;
699
+ transition: all 0.4s !important;
700
+ }
701
+ @media (min-width: 1200px) {
702
+ .carousel__card {
703
+ min-width: 280px;
704
+ width: 280px;
705
+ height: 280px;
706
+ }
707
+ }
708
+ @media (max-width: 1199px) {
709
+ .carousel__card {
710
+ min-width: 242px;
711
+ width: 242px;
712
+ height: 242px;
713
+ }
714
+ }
715
+ @media (max-width: 767px) {
716
+ .carousel__card {
717
+ min-width: 336px;
718
+ width: 336px;
719
+ height: 336px;
720
+ scroll-snap-stop: always;
721
+ }
722
+ }
723
+ .carousel__card:focus,
724
+ .carousel__card:has(:focus-visible) {
725
+ outline: var(--ring-outline-color) solid var(--ring-outline-width) !important;
726
+ outline-offset: var(--ring-outline-offset) !important;
727
+ }
728
+ .carousel__card:hover {
729
+ background-color: var(--color-background-neutral-hover);
730
+ }
731
+ .carousel__card:focus {
732
+ background-color: var(--color-background-neutral-hover);
733
+ }
734
+ .carousel__card-content {
735
+ height: 100%;
736
+ font-weight: normal;
737
+ padding: 24px;
738
+ padding: var(--size-24);
739
+ }
740
+ .carousel__scroll-button {
741
+ width: 32px;
742
+ width: var(--size-32);
743
+ height: 32px;
744
+ height: var(--size-32);
745
+ align-items: center;
746
+ justify-content: center;
747
+ }
748
+ .carousel__indicators {
749
+ display: flex;
750
+ justify-content: center;
751
+ padding-top: 8px;
752
+ padding-top: var(--size-8);
753
+ gap: 8px;
754
+ gap: var(--size-8);
755
+ }
756
+ .carousel__indicator {
757
+ width: 12px;
758
+ width: var(--size-12);
759
+ height: 12px;
760
+ height: var(--size-12);
761
+ border-radius: 8px;
762
+ border-radius: var(--size-8);
763
+ background: #c9cbce;
764
+ background: var(--color-interactive-secondary);
765
+ border: none;
766
+ -webkit-appearance: none;
767
+ -moz-appearance: none;
768
+ appearance: none;
769
+ transition: all 0.1s;
770
+ }
771
+ .carousel__indicator:hover {
772
+ width: 16px;
773
+ width: var(--size-16);
774
+ }
775
+ .carousel__indicator--selected,
776
+ .carousel__indicator--selected:hover {
777
+ background: var(--color-interactive-primary);
778
+ width: 24px;
779
+ width: var(--size-24);
780
+ }
646
781
  .np-checkbox-button input[type="checkbox"] {
647
782
  position: absolute;
648
783
  width: 24px;
package/src/main.less CHANGED
@@ -5,6 +5,7 @@
5
5
  @import "./badge/Badge.less";
6
6
  @import "./button/Button.less";
7
7
  @import "./card/Card.less";
8
+ @import "./carousel/Carousel.less";
8
9
  @import "./checkboxButton/CheckboxButton.less";
9
10
  @import "./chips/Chip.less";
10
11
  @import "./circularButton/CircularButton.less";
@@ -1,7 +1,7 @@
1
- import { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
2
  import { StarFill } from '@transferwise/icons';
3
3
 
4
- import PromoCard, { PromoCardCheckedProps, PromoCardLinkProps } from './PromoCard';
4
+ import PromoCard, { type PromoCardCheckedProps, type PromoCardLinkProps } from './PromoCard';
5
5
 
6
6
  const meta: Meta<typeof PromoCard> = {
7
7
  component: PromoCard,