@transferwise/components 0.0.0-experimental-bb7ed4a → 0.0.0-experimental-137c428
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.
- package/build/index.js +212 -12
- package/build/index.js.map +1 -1
- package/build/index.mjs +213 -14
- package/build/index.mjs.map +1 -1
- package/build/main.css +125 -0
- package/build/styles/carousel/Carousel.css +125 -0
- package/build/styles/main.css +125 -0
- package/build/types/carousel/Carousel.d.ts +19 -0
- package/build/types/carousel/Carousel.d.ts.map +1 -0
- package/build/types/carousel/Carousel.test.d.ts +2 -0
- package/build/types/carousel/Carousel.test.d.ts.map +1 -0
- package/build/types/carousel/index.d.ts +3 -0
- package/build/types/carousel/index.d.ts.map +1 -0
- package/build/types/common/dateUtils/index.d.ts +0 -1
- package/build/types/common/dateUtils/index.d.ts.map +1 -1
- package/build/types/dateLookup/DateLookup.d.ts +2 -2
- package/build/types/dateLookup/DateLookup.d.ts.map +1 -1
- package/build/types/dateLookup/dayCalendar/table/DayCalendarTable.d.ts +1 -1
- package/build/types/dateLookup/dayCalendar/table/DayCalendarTable.d.ts.map +1 -1
- package/build/types/index.d.ts +2 -0
- package/build/types/index.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/carousel/Carousel.css +125 -0
- package/src/carousel/Carousel.less +125 -0
- package/src/carousel/Carousel.story.tsx +62 -0
- package/src/carousel/Carousel.test.tsx +221 -0
- package/src/carousel/Carousel.tsx +269 -0
- package/src/carousel/index.ts +3 -0
- package/src/common/dateUtils/index.ts +0 -1
- package/src/dateLookup/DateLookup.tests.story.tsx +10 -44
- package/src/dateLookup/DateLookup.tsx +12 -9
- package/src/dateLookup/dayCalendar/table/DayCalendarTable.tsx +2 -5
- package/src/index.ts +2 -0
- package/src/main.css +125 -0
- package/src/main.less +1 -0
- package/build/types/common/dateUtils/getDateView/getDateView.d.ts +0 -2
- package/build/types/common/dateUtils/getDateView/getDateView.d.ts.map +0 -1
- package/src/common/dateUtils/getDateView/getDateView.ts +0 -5
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
.carousel {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
overflow-x: scroll;
|
|
5
|
+
overflow-y: hidden;
|
|
6
|
+
scroll-snap-type: x mandatory;
|
|
7
|
+
scroll-behavior: smooth;
|
|
8
|
+
gap: var(--size-16);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.carousel__header {
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
min-height: var(--size-32);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.carousel__card,
|
|
19
|
+
.carousel__card:hover,
|
|
20
|
+
.carousel__card:focus {
|
|
21
|
+
text-decoration: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.carousel__card {
|
|
25
|
+
position: relative;
|
|
26
|
+
text-align: left;
|
|
27
|
+
border: none;
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
background: var(--color-background-neutral);
|
|
30
|
+
border-radius: var(--size-16);
|
|
31
|
+
scroll-snap-align: center;
|
|
32
|
+
-webkit-scroll-snap-align: center;
|
|
33
|
+
transition: all 0.4s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@media (--screen-xl) {
|
|
37
|
+
.carousel__card {
|
|
38
|
+
min-width: 280px;
|
|
39
|
+
width: 280px;
|
|
40
|
+
height: 280px;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@media (--screen-lg-max) {
|
|
46
|
+
.carousel__card {
|
|
47
|
+
min-width: 242px;
|
|
48
|
+
width: 242px;
|
|
49
|
+
height: 242px;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@media (--screen-sm-max) {
|
|
55
|
+
.carousel__card {
|
|
56
|
+
min-width: 336px;
|
|
57
|
+
width: 336px;
|
|
58
|
+
height: 336px;
|
|
59
|
+
scroll-snap-stop: always;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.carousel__card:focus {
|
|
64
|
+
outline: 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.carousel__card:focus:after {
|
|
68
|
+
content: '';
|
|
69
|
+
position: absolute;
|
|
70
|
+
z-index: 4;
|
|
71
|
+
top: 0;
|
|
72
|
+
left: 0;
|
|
73
|
+
width: 100%;
|
|
74
|
+
height: 100%;
|
|
75
|
+
border-radius: var(--size-16);
|
|
76
|
+
box-shadow: inset 0 0 0 2px var(--ring-outline-color);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.carousel__card:hover {
|
|
80
|
+
background-color: var(--color-background-neutral-hover);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.carousel__card:focus {
|
|
84
|
+
background-color: var(--color-background-neutral-hover);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.carousel__card-content {
|
|
88
|
+
height: 100%;
|
|
89
|
+
font-weight: normal;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.carousel__scroll-button {
|
|
93
|
+
width: var(--size-32);
|
|
94
|
+
height: var(--size-32) !important;
|
|
95
|
+
padding: 0 !important;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.carousel__indicators {
|
|
101
|
+
display: flex;
|
|
102
|
+
justify-content: center;
|
|
103
|
+
padding-top: var(--size-8);
|
|
104
|
+
gap: var(--size-8);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.carousel__indicator {
|
|
108
|
+
width: var(--size-12);
|
|
109
|
+
height: var(--size-12);
|
|
110
|
+
border-radius: var(--size-8);
|
|
111
|
+
background: var(--color-interactive-secondary);
|
|
112
|
+
border: none;
|
|
113
|
+
appearance: none;
|
|
114
|
+
transition: all 0.1s;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.carousel__indicator:hover {
|
|
118
|
+
width: var(--size-16)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.carousel__indicator--selected,
|
|
122
|
+
.carousel__indicator--selected:hover {
|
|
123
|
+
background: var(--color-interactive-primary);
|
|
124
|
+
width: var(--size-24);
|
|
125
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { StoryFn } from '@storybook/react';
|
|
2
|
+
import { Illustration } from '@wise/art';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import Display from '../display';
|
|
6
|
+
import Title from '../title';
|
|
7
|
+
|
|
8
|
+
import Carousel, { type CarouselCard } from './Carousel';
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
component: Carousel,
|
|
12
|
+
title: 'Navigation/Carousel',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const carouselCards: CarouselCard[] = [
|
|
16
|
+
{
|
|
17
|
+
id: 'YOUR_INVOICE',
|
|
18
|
+
type: 'anchor',
|
|
19
|
+
href: 'https://wise.com',
|
|
20
|
+
onClick: () => {},
|
|
21
|
+
content: (
|
|
22
|
+
<div className="d-flex p-a-1 p-t-0 flex-column align-items-center">
|
|
23
|
+
<div style={{ position: 'absolute' }}>
|
|
24
|
+
<Illustration disablePadding name="globe" size="small" alt="" />
|
|
25
|
+
</div>
|
|
26
|
+
<div style={{ position: 'absolute', zIndex: '2', bottom: '8px', left: '16px' }}>
|
|
27
|
+
<Display type="display-small">Here there, everywhere</Display>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'YOUR_WEBSITE',
|
|
34
|
+
type: 'button',
|
|
35
|
+
onClick: () => {},
|
|
36
|
+
styles: {
|
|
37
|
+
backgroundColor: 'var(--color-interactive-accent)',
|
|
38
|
+
},
|
|
39
|
+
content: <Title type="title-screen">Wise.</Title>,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'SHOP_OR_STALL',
|
|
43
|
+
type: 'anchor',
|
|
44
|
+
href: 'https://wise.com',
|
|
45
|
+
onClick: () => {},
|
|
46
|
+
styles: {
|
|
47
|
+
backgroundImage:
|
|
48
|
+
'url(https://wise.com/public-resources/assets/team-page/tapestry/team_member_tapestry_01.jpg)',
|
|
49
|
+
backgroundSize: 'cover',
|
|
50
|
+
},
|
|
51
|
+
content: <Display type="display-small">Get Quick Pay</Display>,
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const Template: StoryFn = (args) => {
|
|
56
|
+
return <Carousel header="Pretty nifty stuff" cards={carouselCards} {...args} />;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const CarouselDefault = {
|
|
60
|
+
render: Template,
|
|
61
|
+
args: {},
|
|
62
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
|
|
4
|
+
import Display from '../display';
|
|
5
|
+
import Title from '../title';
|
|
6
|
+
|
|
7
|
+
import Carousel, { type CarouselCard } from './Carousel';
|
|
8
|
+
|
|
9
|
+
jest.mock('@wise/components-theming', () => ({
|
|
10
|
+
...jest.requireActual<Record<string, unknown>>('@wise/components-theming'),
|
|
11
|
+
useTheme: jest.fn().mockReturnValue({ theme: 'personal' }),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const yourInvoiceOnClick = jest.fn();
|
|
15
|
+
|
|
16
|
+
const carouselCards: CarouselCard[] = [
|
|
17
|
+
{
|
|
18
|
+
id: 'YOUR_INVOICE',
|
|
19
|
+
type: 'anchor',
|
|
20
|
+
href: 'https://wise.com',
|
|
21
|
+
onClick: yourInvoiceOnClick,
|
|
22
|
+
content: <div>Add a link in accounting software like Xero or QuickBooks.</div>,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'YOUR_WEBSITE',
|
|
26
|
+
type: 'button',
|
|
27
|
+
onClick: () => {},
|
|
28
|
+
content: <Title type="title-screen">Wise.</Title>,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'SHOP_OR_STALL',
|
|
32
|
+
type: 'anchor',
|
|
33
|
+
href: 'https://wise.com',
|
|
34
|
+
onClick: () => {},
|
|
35
|
+
content: <Display type="display-small">Get Quick Pay</Display>,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const onClick = jest.fn();
|
|
40
|
+
|
|
41
|
+
const renderCarousel = () => {
|
|
42
|
+
render(<Carousel header="Cool stuff" cards={carouselCards} onClick={onClick} />);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe('Carousel', () => {
|
|
46
|
+
const focusSpy = jest.fn();
|
|
47
|
+
const scrollBy = jest.fn();
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
// eslint-disable-next-line functional/immutable-data
|
|
51
|
+
window.HTMLElement.prototype.scrollIntoView = focusSpy;
|
|
52
|
+
// eslint-disable-next-line functional/immutable-data
|
|
53
|
+
window.HTMLElement.prototype.scrollBy = scrollBy;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
focusSpy.mockClear();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('shows expected content on page', async () => {
|
|
61
|
+
renderCarousel();
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
screen.getByText('Add a link in accounting software like Xero or QuickBooks.'),
|
|
65
|
+
).toBeInTheDocument();
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText('Wise.')).toBeInTheDocument();
|
|
68
|
+
|
|
69
|
+
expect(screen.getByText('Get Quick Pay')).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('allows user to scroll carousel to the right when at start', () => {
|
|
73
|
+
mockPositions(948, 903);
|
|
74
|
+
|
|
75
|
+
renderCarousel();
|
|
76
|
+
|
|
77
|
+
const scrollLeftButton = screen.getByTestId('scroll-carousel-left');
|
|
78
|
+
const scrollRightButton = screen.getByTestId('scroll-carousel-right');
|
|
79
|
+
|
|
80
|
+
expect(scrollLeftButton).toBeDisabled();
|
|
81
|
+
expect(scrollRightButton).toBeEnabled();
|
|
82
|
+
|
|
83
|
+
userEvent.click(scrollRightButton);
|
|
84
|
+
|
|
85
|
+
expect(scrollBy).toHaveBeenCalledWith({ left: 300, behavior: 'smooth' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('allows user to scroll carousel to the left or right when in middle', () => {
|
|
89
|
+
mockPositions(948, 903);
|
|
90
|
+
|
|
91
|
+
renderCarousel();
|
|
92
|
+
|
|
93
|
+
const carousel = screen.getByRole('list');
|
|
94
|
+
fireEvent.scroll(carousel, { target: { scrollLeft: 10 } });
|
|
95
|
+
|
|
96
|
+
const scrollLeftButton = screen.getByTestId('scroll-carousel-left');
|
|
97
|
+
const scrollRightButton = screen.getByTestId('scroll-carousel-right');
|
|
98
|
+
|
|
99
|
+
expect(scrollLeftButton).toBeEnabled();
|
|
100
|
+
expect(scrollRightButton).toBeEnabled();
|
|
101
|
+
|
|
102
|
+
userEvent.click(scrollLeftButton);
|
|
103
|
+
|
|
104
|
+
expect(scrollBy).toHaveBeenCalledWith({ left: -300, behavior: 'smooth' });
|
|
105
|
+
|
|
106
|
+
userEvent.click(scrollRightButton);
|
|
107
|
+
|
|
108
|
+
expect(scrollBy).toHaveBeenCalledWith({ left: 300, behavior: 'smooth' });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('allows user to scroll carousel to the left when at end', () => {
|
|
112
|
+
mockPositions(948, 903);
|
|
113
|
+
|
|
114
|
+
renderCarousel();
|
|
115
|
+
|
|
116
|
+
const carousel = screen.getByRole('list');
|
|
117
|
+
fireEvent.scroll(carousel, { target: { scrollLeft: 50 } });
|
|
118
|
+
|
|
119
|
+
const scrollLeftButton = screen.getByTestId('scroll-carousel-left');
|
|
120
|
+
const scrollRightButton = screen.getByTestId('scroll-carousel-right');
|
|
121
|
+
|
|
122
|
+
expect(scrollLeftButton).toBeEnabled();
|
|
123
|
+
expect(scrollRightButton).toBeDisabled();
|
|
124
|
+
|
|
125
|
+
userEvent.click(scrollLeftButton);
|
|
126
|
+
|
|
127
|
+
expect(scrollBy).toHaveBeenCalledWith({ left: -300, behavior: 'smooth' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('allows user to go through carousel by indicator', async () => {
|
|
131
|
+
mockPositions(948, 903);
|
|
132
|
+
|
|
133
|
+
renderCarousel();
|
|
134
|
+
|
|
135
|
+
const yourInvoiceIndicator = screen.getByTestId('YOUR_INVOICE-indicator');
|
|
136
|
+
const yourWebsiteIndicator = screen.getByTestId('YOUR_WEBSITE-indicator');
|
|
137
|
+
const shopOrStallIndicator = screen.getByTestId('SHOP_OR_STALL-indicator');
|
|
138
|
+
const firstCard = getFirstCard();
|
|
139
|
+
const secondCard = getSecondCard();
|
|
140
|
+
const thirdCard = getThirdCard();
|
|
141
|
+
|
|
142
|
+
expect(focusSpy).toHaveBeenCalledTimes(0);
|
|
143
|
+
expect(firstCard).toHaveClass('carousel__card--focused');
|
|
144
|
+
|
|
145
|
+
userEvent.click(yourWebsiteIndicator);
|
|
146
|
+
|
|
147
|
+
expect(focusSpy).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(secondCard).toHaveClass('carousel__card--focused');
|
|
149
|
+
|
|
150
|
+
userEvent.click(shopOrStallIndicator);
|
|
151
|
+
|
|
152
|
+
expect(focusSpy).toHaveBeenCalledTimes(2);
|
|
153
|
+
expect(thirdCard).toHaveClass('carousel__card--focused');
|
|
154
|
+
|
|
155
|
+
userEvent.click(yourInvoiceIndicator);
|
|
156
|
+
|
|
157
|
+
expect(focusSpy).toHaveBeenCalledTimes(3);
|
|
158
|
+
expect(firstCard).toHaveClass('carousel__card--focused');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('is focused on the first element and lets user navigate through them', () => {
|
|
162
|
+
renderCarousel();
|
|
163
|
+
|
|
164
|
+
const firstCard = getFirstCard();
|
|
165
|
+
const secondCard = getSecondCard();
|
|
166
|
+
const thirdCard = getThirdCard();
|
|
167
|
+
|
|
168
|
+
expect(firstCard).toBeInTheDocument();
|
|
169
|
+
|
|
170
|
+
firstCard.focus();
|
|
171
|
+
expect(firstCard).toHaveFocus();
|
|
172
|
+
|
|
173
|
+
userEvent.keyboard('{arrowright}');
|
|
174
|
+
|
|
175
|
+
expect(secondCard).toHaveFocus();
|
|
176
|
+
|
|
177
|
+
userEvent.keyboard('{arrowright}');
|
|
178
|
+
|
|
179
|
+
expect(thirdCard).toHaveFocus();
|
|
180
|
+
|
|
181
|
+
userEvent.keyboard('{arrowleft}');
|
|
182
|
+
|
|
183
|
+
expect(secondCard).toHaveFocus();
|
|
184
|
+
|
|
185
|
+
userEvent.keyboard('{arrowleft}');
|
|
186
|
+
|
|
187
|
+
expect(firstCard).toHaveFocus();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('calls onClick when a card is clicked', () => {
|
|
191
|
+
renderCarousel();
|
|
192
|
+
|
|
193
|
+
userEvent.click(getFirstCard());
|
|
194
|
+
|
|
195
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
196
|
+
expect(yourInvoiceOnClick).toHaveBeenCalledTimes(1);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const mockPositions = (scrollWidth: number, offsetWidth: number) => {
|
|
201
|
+
// eslint-disable-next-line functional/immutable-data
|
|
202
|
+
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
|
|
203
|
+
configurable: true,
|
|
204
|
+
value: scrollWidth,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// eslint-disable-next-line functional/immutable-data
|
|
208
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
|
209
|
+
configurable: true,
|
|
210
|
+
value: offsetWidth,
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const getFirstCard = () =>
|
|
215
|
+
screen.getByRole('listitem', {
|
|
216
|
+
name: /Add a link in accounting software like Xero or QuickBooks\./,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const getSecondCard = () => screen.getByRole('listitem', { name: /Wise\./ });
|
|
220
|
+
|
|
221
|
+
const getThirdCard = () => screen.getByRole('listitem', { name: /Get Quick Pay/ });
|
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
|
|
8
|
+
export type CarouselCard = {
|
|
9
|
+
id: string;
|
|
10
|
+
type: 'anchor' | 'button';
|
|
11
|
+
href?: string;
|
|
12
|
+
onClick?: () => void;
|
|
13
|
+
className?: string;
|
|
14
|
+
styles?: CSSProperties;
|
|
15
|
+
content: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export interface CarouselProps {
|
|
19
|
+
header: string | ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
cards: CarouselCard[];
|
|
22
|
+
onClick?: (cardId: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const Carousel: React.FC<CarouselProps> = ({ header, className, cards, onClick }) => {
|
|
26
|
+
const [scrollPosition, setScrollPosition] = useState(0);
|
|
27
|
+
const [previousScrollPosition, setPreviousScrollPosition] = useState(0);
|
|
28
|
+
const [scrollIsAtEnd, setScrollIsAtEnd] = useState(false);
|
|
29
|
+
const [visibleCardOnMobileView, setVisibleCardOnMobileView] = useState<string>('');
|
|
30
|
+
const carouselElementRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const caraouselCardsRef = useRef<(HTMLAnchorElement | HTMLButtonElement)[]>([]);
|
|
32
|
+
|
|
33
|
+
const isLeftActionButtonEnabled = scrollPosition > 8;
|
|
34
|
+
|
|
35
|
+
const areActionButtonsEnabled = isLeftActionButtonEnabled || !scrollIsAtEnd;
|
|
36
|
+
|
|
37
|
+
const [focusedCard, setFocusedCard] = useState(cards?.[0]?.id);
|
|
38
|
+
|
|
39
|
+
const updateScrollButtonsState = () => {
|
|
40
|
+
if (carouselElementRef.current) {
|
|
41
|
+
const { scrollWidth, offsetWidth } = carouselElementRef.current;
|
|
42
|
+
|
|
43
|
+
const scrollAtEnd = scrollWidth - offsetWidth <= scrollPosition + 8;
|
|
44
|
+
setScrollIsAtEnd(scrollAtEnd);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const scrollDirecton = scrollPosition > previousScrollPosition ? 'right' : 'left';
|
|
48
|
+
|
|
49
|
+
const cardsInFullViewIds: string[] = [];
|
|
50
|
+
caraouselCardsRef.current.forEach((card) => {
|
|
51
|
+
if (isVisible(carouselElementRef.current as HTMLElement, card as HTMLElement)) {
|
|
52
|
+
// eslint-disable-next-line functional/immutable-data
|
|
53
|
+
cardsInFullViewIds.push(card.getAttribute('id') ?? '');
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (cardsInFullViewIds.length >= 1) {
|
|
58
|
+
const visibleCardIndex = scrollDirecton === 'right' ? cardsInFullViewIds.length - 1 : 0;
|
|
59
|
+
const visibleCardId = cardsInFullViewIds[visibleCardIndex];
|
|
60
|
+
setVisibleCardOnMobileView(visibleCardId);
|
|
61
|
+
setFocusedCard(visibleCardId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setPreviousScrollPosition(scrollPosition);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const scrollCarousel = (direction: 'left' | 'right' = 'right') => {
|
|
68
|
+
if (carouselElementRef.current) {
|
|
69
|
+
const { scrollWidth } = carouselElementRef.current;
|
|
70
|
+
|
|
71
|
+
const cardWidth = scrollWidth / caraouselCardsRef.current.length;
|
|
72
|
+
|
|
73
|
+
const res = Math.floor(cardWidth - cardWidth * 0.05);
|
|
74
|
+
|
|
75
|
+
carouselElementRef.current.scrollBy({
|
|
76
|
+
left: direction === 'right' ? res : -res,
|
|
77
|
+
behavior: 'smooth',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleOnKeyDown = (
|
|
83
|
+
event: React.KeyboardEvent<HTMLAnchorElement | HTMLButtonElement>,
|
|
84
|
+
index: number,
|
|
85
|
+
) => {
|
|
86
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
|
87
|
+
const nextIndex = event.key === 'ArrowRight' ? index + 1 : index - 1;
|
|
88
|
+
const nextCard = cards[nextIndex];
|
|
89
|
+
if (nextCard) {
|
|
90
|
+
caraouselCardsRef.current[nextIndex].focus();
|
|
91
|
+
scrollCardIntoView(caraouselCardsRef.current[nextIndex], nextCard);
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const scrollCardIntoView = (element: HTMLElement, card: CarouselCard) => {
|
|
98
|
+
element.scrollIntoView({
|
|
99
|
+
behavior: 'smooth',
|
|
100
|
+
block: 'nearest',
|
|
101
|
+
inline: 'center',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
setFocusedCard(card.id);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
updateScrollButtonsState();
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
}, [scrollPosition]);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
window.addEventListener('resize', updateScrollButtonsState);
|
|
114
|
+
|
|
115
|
+
return () => {
|
|
116
|
+
window.removeEventListener('resize', updateScrollButtonsState);
|
|
117
|
+
};
|
|
118
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className={className}>
|
|
123
|
+
<div className="d-flex justify-content-between m-b-1 carousel__header">
|
|
124
|
+
{typeof header === 'string' ? (
|
|
125
|
+
<Title as="span" type="title-body">
|
|
126
|
+
{header}
|
|
127
|
+
</Title>
|
|
128
|
+
) : (
|
|
129
|
+
header
|
|
130
|
+
)}
|
|
131
|
+
{areActionButtonsEnabled ? (
|
|
132
|
+
<div className="hidden-xs">
|
|
133
|
+
<ActionButton
|
|
134
|
+
className="carousel__scroll-button"
|
|
135
|
+
tabIndex={-1}
|
|
136
|
+
priority="secondary"
|
|
137
|
+
disabled={!isLeftActionButtonEnabled}
|
|
138
|
+
aria-hidden="true"
|
|
139
|
+
data-testid="scroll-carousel-left"
|
|
140
|
+
onClick={() => scrollCarousel('left')}
|
|
141
|
+
>
|
|
142
|
+
<ChevronLeft />
|
|
143
|
+
</ActionButton>
|
|
144
|
+
<ActionButton
|
|
145
|
+
tabIndex={-1}
|
|
146
|
+
className="carousel__scroll-button m-l-1"
|
|
147
|
+
priority="secondary"
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
data-testid="scroll-carousel-right"
|
|
150
|
+
disabled={scrollIsAtEnd}
|
|
151
|
+
onClick={() => scrollCarousel()}
|
|
152
|
+
>
|
|
153
|
+
<ChevronRight />
|
|
154
|
+
</ActionButton>
|
|
155
|
+
</div>
|
|
156
|
+
) : null}
|
|
157
|
+
</div>
|
|
158
|
+
<div
|
|
159
|
+
ref={carouselElementRef}
|
|
160
|
+
tabIndex={-1}
|
|
161
|
+
role="list"
|
|
162
|
+
className="carousel"
|
|
163
|
+
onScroll={(event) => {
|
|
164
|
+
const target = event.target as HTMLElement;
|
|
165
|
+
setScrollPosition(target.scrollLeft);
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{cards?.map((card, index) => {
|
|
169
|
+
const sharedProps = {
|
|
170
|
+
id: card.id,
|
|
171
|
+
className: classNames('carousel__card', {
|
|
172
|
+
'carousel__card--focused': card.id === focusedCard,
|
|
173
|
+
}),
|
|
174
|
+
role: 'listitem',
|
|
175
|
+
'aria-labelledby': `${card.id}-content`,
|
|
176
|
+
onClick: () => {
|
|
177
|
+
card.onClick?.();
|
|
178
|
+
onClick?.(card.id);
|
|
179
|
+
},
|
|
180
|
+
onFocus: (event: React.FocusEvent<HTMLAnchorElement | HTMLButtonElement>) => {
|
|
181
|
+
scrollCardIntoView(event.currentTarget, card);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const cardContent = (
|
|
186
|
+
<div
|
|
187
|
+
id={`${card.id}-content`}
|
|
188
|
+
className={classNames('carousel__card-content', 'p-a-1', {
|
|
189
|
+
[card.className ?? '']: !!card.className,
|
|
190
|
+
})}
|
|
191
|
+
// eslint-disable-next-line react/forbid-dom-props
|
|
192
|
+
style={card.styles}
|
|
193
|
+
>
|
|
194
|
+
{card.content}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (card.type === 'button') {
|
|
199
|
+
return (
|
|
200
|
+
<button
|
|
201
|
+
{...sharedProps}
|
|
202
|
+
ref={(el) => {
|
|
203
|
+
if (el) {
|
|
204
|
+
// eslint-disable-next-line functional/immutable-data
|
|
205
|
+
caraouselCardsRef.current[index] = el;
|
|
206
|
+
}
|
|
207
|
+
}}
|
|
208
|
+
key={card.id}
|
|
209
|
+
type="button"
|
|
210
|
+
onKeyDown={(event) => handleOnKeyDown(event, index)}
|
|
211
|
+
>
|
|
212
|
+
{cardContent}
|
|
213
|
+
</button>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<a
|
|
219
|
+
{...sharedProps}
|
|
220
|
+
ref={(el) => {
|
|
221
|
+
if (el) {
|
|
222
|
+
// eslint-disable-next-line functional/immutable-data
|
|
223
|
+
caraouselCardsRef.current[index] = el;
|
|
224
|
+
}
|
|
225
|
+
}}
|
|
226
|
+
key={card.id}
|
|
227
|
+
target="_blank"
|
|
228
|
+
rel="noreferrer"
|
|
229
|
+
href={card.href}
|
|
230
|
+
onKeyDown={(event) => handleOnKeyDown(event, index)}
|
|
231
|
+
>
|
|
232
|
+
{cardContent}
|
|
233
|
+
</a>
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
236
|
+
</div>
|
|
237
|
+
<div className="visible-xs">
|
|
238
|
+
<div className="carousel__indicators">
|
|
239
|
+
{cards?.map((card, index) => (
|
|
240
|
+
<button
|
|
241
|
+
key={`${card.id}-indicator`}
|
|
242
|
+
data-testid={`${card.id}-indicator`}
|
|
243
|
+
tabIndex={-1}
|
|
244
|
+
type="button"
|
|
245
|
+
className={classNames('carousel__indicator', {
|
|
246
|
+
'carousel__indicator--selected': card.id === visibleCardOnMobileView,
|
|
247
|
+
})}
|
|
248
|
+
onClick={() => {
|
|
249
|
+
scrollCardIntoView(caraouselCardsRef.current[index], card);
|
|
250
|
+
}}
|
|
251
|
+
/>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const isVisible = (container: HTMLElement, el: HTMLElement) => {
|
|
260
|
+
const cWidth = container.offsetWidth;
|
|
261
|
+
const cScrollOffset = container.scrollLeft;
|
|
262
|
+
|
|
263
|
+
const elemLeft = el.offsetLeft - container.offsetLeft;
|
|
264
|
+
const elemRight = elemLeft + el.offsetWidth;
|
|
265
|
+
|
|
266
|
+
return elemLeft >= cScrollOffset && elemRight <= cScrollOffset + cWidth;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export default Carousel;
|
|
@@ -5,4 +5,3 @@ export { getMonthNames } from './getMonthNames/getMonthNames';
|
|
|
5
5
|
export { isDateValid } from './isDateValid/isDateValid';
|
|
6
6
|
export { isMonthAndYearFormat } from './isMonthAndYearFormat/isMonthAndYearFormat';
|
|
7
7
|
export { MDY, YMD } from './getFormatForLocale/getFormatForLocale';
|
|
8
|
-
export { returnDateView } from './getDateView/getDateView';
|