richie-education 2.34.1-dev47 → 2.34.1-dev51
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/js/pages/DashboardCreditCardsManagement/index.spec.tsx +9 -8
- package/js/widgets/Slider/components/Slide.tsx +20 -0
- package/js/widgets/Slider/components/SlidePanel.tsx +83 -0
- package/js/widgets/Slider/components/Slideshow.tsx +58 -0
- package/js/widgets/Slider/index.spec.tsx +167 -0
- package/js/widgets/Slider/index.tsx +119 -0
- package/js/widgets/Slider/types/index.ts +8 -0
- package/js/widgets/index.tsx +3 -0
- package/package.json +3 -1
- package/scss/colors/_theme.scss +6 -4
- package/scss/components/templates/richie/slider/_slider.scss +165 -99
|
@@ -134,13 +134,14 @@ describe('<DashboardCreditCardsManagement/>', () => {
|
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
it('renders the correct label for an expiration date that will soon expire', async () => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
const now = new Date();
|
|
138
|
+
const expirationDate = faker.date.between({
|
|
139
|
+
from: now,
|
|
140
|
+
to: new Date(now.getFullYear(), now.getMonth() + 4, 0, 23, 59, 59),
|
|
141
|
+
});
|
|
141
142
|
const creditCard: CreditCard = CreditCardFactory({
|
|
142
|
-
expiration_month:
|
|
143
|
-
expiration_year:
|
|
143
|
+
expiration_month: expirationDate.getMonth() + 1,
|
|
144
|
+
expiration_year: expirationDate.getFullYear(),
|
|
144
145
|
}).one();
|
|
145
146
|
|
|
146
147
|
fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard]);
|
|
@@ -151,11 +152,11 @@ describe('<DashboardCreditCardsManagement/>', () => {
|
|
|
151
152
|
expect(screen.queryByText('An error occurred', { exact: false })).toBeNull();
|
|
152
153
|
const element = await screen.findByText(
|
|
153
154
|
'Expires on ' +
|
|
154
|
-
(
|
|
155
|
+
(expirationDate.getMonth() + 1).toLocaleString(undefined, {
|
|
155
156
|
minimumIntegerDigits: 2,
|
|
156
157
|
}) +
|
|
157
158
|
'/' +
|
|
158
|
-
|
|
159
|
+
expirationDate.getFullYear(),
|
|
159
160
|
);
|
|
160
161
|
expect(element.classList).toContain('dashboard-credit-card__expiration');
|
|
161
162
|
expect(element.classList).not.toContain('dashboard-credit-card__expiration--expired');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Slide as SlideType } from '../types';
|
|
2
|
+
|
|
3
|
+
const Slide = ({ slide }: { slide: SlideType }) => (
|
|
4
|
+
<div className="slider__slide">
|
|
5
|
+
{slide.link_url ? (
|
|
6
|
+
<a
|
|
7
|
+
href={slide.link_url}
|
|
8
|
+
target={slide.link_open_blank ? '_blank' : '_self'}
|
|
9
|
+
rel="noopener noreferrer"
|
|
10
|
+
title={`Go to ${slide.link_url}`}
|
|
11
|
+
>
|
|
12
|
+
<img src={slide.image} alt={slide.title} loading="lazy" />
|
|
13
|
+
</a>
|
|
14
|
+
) : (
|
|
15
|
+
<img src={slide.image} alt={slide.title} loading="lazy" />
|
|
16
|
+
)}
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export default Slide;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
3
|
+
import { Slide } from '../types';
|
|
4
|
+
|
|
5
|
+
const messages = defineMessages({
|
|
6
|
+
goToSlide: {
|
|
7
|
+
id: 'widgets.Slider.components.SlidePanel.goToSlide',
|
|
8
|
+
defaultMessage: 'Go to slide {slideIndex}',
|
|
9
|
+
description: 'Aria label for the bullet buttons to go to a given slide.',
|
|
10
|
+
},
|
|
11
|
+
slideAriaLabel: {
|
|
12
|
+
id: 'widgets.Slider.components.SlidePanel.slideAriaLabel',
|
|
13
|
+
defaultMessage: 'Slide {slideNumber}: {slideTitle}',
|
|
14
|
+
description: 'Aria label for the current slide.',
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
type SlidePanelProps = {
|
|
19
|
+
slides: readonly Slide[];
|
|
20
|
+
activeSlideIndex: number;
|
|
21
|
+
isTransitioning: boolean;
|
|
22
|
+
onBulletClick: (index: number) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* This component is used to display the panel for the slideshow.
|
|
27
|
+
* It renders the textual content of the current slide and also the bullet points.
|
|
28
|
+
*/
|
|
29
|
+
const SlidePanel = ({
|
|
30
|
+
slides,
|
|
31
|
+
activeSlideIndex,
|
|
32
|
+
onBulletClick,
|
|
33
|
+
isTransitioning,
|
|
34
|
+
}: SlidePanelProps) => {
|
|
35
|
+
const intl = useIntl();
|
|
36
|
+
const hasSlideContent = slides.some((slide) => slide.content);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<section className="slider__panel" aria-label="Slide information">
|
|
40
|
+
<div
|
|
41
|
+
className={classNames('slide__content', {
|
|
42
|
+
'slide__content--transitioning': isTransitioning,
|
|
43
|
+
})}
|
|
44
|
+
role="group"
|
|
45
|
+
aria-roledescription="slide"
|
|
46
|
+
aria-label={intl.formatMessage(messages.slideAriaLabel, {
|
|
47
|
+
slideNumber: activeSlideIndex + 1,
|
|
48
|
+
slideTitle: slides[activeSlideIndex].title,
|
|
49
|
+
})}
|
|
50
|
+
>
|
|
51
|
+
<strong className="slide__title">
|
|
52
|
+
<span>{slides[activeSlideIndex].title}</span>
|
|
53
|
+
</strong>
|
|
54
|
+
{hasSlideContent && (
|
|
55
|
+
<div
|
|
56
|
+
className="slide__description"
|
|
57
|
+
// eslint-disable-next-line react/no-danger
|
|
58
|
+
dangerouslySetInnerHTML={{ __html: slides[activeSlideIndex].content }}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
<div className="slider__bullet-list" role="tablist" aria-label="Slide navigation">
|
|
63
|
+
{slides.map((slide, index) => (
|
|
64
|
+
<button
|
|
65
|
+
key={slide.pk}
|
|
66
|
+
className={classNames('slider__bullet-item', {
|
|
67
|
+
'slider__bullet-item--active': activeSlideIndex === index,
|
|
68
|
+
})}
|
|
69
|
+
onClick={() => onBulletClick(index)}
|
|
70
|
+
role="tab"
|
|
71
|
+
aria-selected={activeSlideIndex === index}
|
|
72
|
+
>
|
|
73
|
+
<span className="offscreen">
|
|
74
|
+
<FormattedMessage {...messages.goToSlide} values={{ slideIndex: index + 1 }} />
|
|
75
|
+
</span>
|
|
76
|
+
</button>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</section>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default SlidePanel;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
2
|
+
import { Icon, IconTypeEnum } from 'components/Icon';
|
|
3
|
+
import { Slide as SlideType } from '../types';
|
|
4
|
+
import Slide from './Slide';
|
|
5
|
+
|
|
6
|
+
const messages = defineMessages({
|
|
7
|
+
nextSlide: {
|
|
8
|
+
id: 'widgets.Slider.components.Slideshow.nextSlide',
|
|
9
|
+
defaultMessage: 'Next slide',
|
|
10
|
+
description: 'Aria label to navigate to the next slide.',
|
|
11
|
+
},
|
|
12
|
+
previousSlide: {
|
|
13
|
+
id: 'widgets.Slider.components.Slideshow.previousSlide',
|
|
14
|
+
defaultMessage: 'Previous slide',
|
|
15
|
+
description: 'Aria label to navigate to the previous slide.',
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
type SlideshowProps = {
|
|
19
|
+
slides: readonly SlideType[];
|
|
20
|
+
onNextSlide: () => void;
|
|
21
|
+
onPreviousSlide: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* This component is used to display the slideshow.
|
|
26
|
+
* It renders the slides and the navigation buttons.
|
|
27
|
+
*/
|
|
28
|
+
const Slideshow = ({ slides, onNextSlide, onPreviousSlide }: SlideshowProps) => {
|
|
29
|
+
const intl = useIntl();
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<div className="slider__slideshow">
|
|
34
|
+
{slides.map((slide) => (
|
|
35
|
+
<Slide key={slide.pk} slide={slide} />
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
<div className="slider__slideshow-overlay">
|
|
39
|
+
<button
|
|
40
|
+
className="slider__navigation-button"
|
|
41
|
+
onClick={onPreviousSlide}
|
|
42
|
+
aria-label={intl.formatMessage(messages.previousSlide)}
|
|
43
|
+
>
|
|
44
|
+
<Icon name={IconTypeEnum.CHEVRON_LEFT_OUTLINE} aria-hidden="true" />
|
|
45
|
+
</button>
|
|
46
|
+
<button
|
|
47
|
+
className="slider__navigation-button"
|
|
48
|
+
onClick={onNextSlide}
|
|
49
|
+
aria-label={intl.formatMessage(messages.nextSlide)}
|
|
50
|
+
>
|
|
51
|
+
<Icon name={IconTypeEnum.CHEVRON_RIGHT_OUTLINE} aria-hidden="true" />
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default Slideshow;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { screen, fireEvent, render, within } from '@testing-library/react';
|
|
2
|
+
import { IntlProvider } from 'react-intl';
|
|
3
|
+
import useEmblaCarousel from 'embla-carousel-react';
|
|
4
|
+
import { Slide } from './types';
|
|
5
|
+
import Slider from '.';
|
|
6
|
+
|
|
7
|
+
// Mock the embla-carousel-react hook
|
|
8
|
+
jest.mock('embla-carousel-react', () => {
|
|
9
|
+
const mockEmblaApi = {
|
|
10
|
+
scrollTo: jest.fn(),
|
|
11
|
+
scrollNext: jest.fn(),
|
|
12
|
+
scrollPrev: jest.fn(),
|
|
13
|
+
on: jest.fn(),
|
|
14
|
+
off: jest.fn(),
|
|
15
|
+
selectedScrollSnap: jest.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return () => [
|
|
19
|
+
jest.fn(), // ref
|
|
20
|
+
mockEmblaApi,
|
|
21
|
+
];
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const mockUseEmblaCarousel = useEmblaCarousel as jest.Mocked<typeof useEmblaCarousel>;
|
|
25
|
+
|
|
26
|
+
describe('<Slider />', () => {
|
|
27
|
+
const mockSlides: Slide[] = [
|
|
28
|
+
{
|
|
29
|
+
pk: '1',
|
|
30
|
+
title: 'Slide 1',
|
|
31
|
+
content: 'Content 1',
|
|
32
|
+
image: 'image1.jpg',
|
|
33
|
+
link_url: 'https://example.com/1',
|
|
34
|
+
link_open_blank: false,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
pk: '2',
|
|
38
|
+
title: 'Slide 2',
|
|
39
|
+
content: 'Content 2',
|
|
40
|
+
image: 'image2.jpg',
|
|
41
|
+
link_url: 'https://example.com/2',
|
|
42
|
+
link_open_blank: false,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
pk: '3',
|
|
46
|
+
title: 'Slide 3',
|
|
47
|
+
content: 'Content 3',
|
|
48
|
+
image: 'image3.jpg',
|
|
49
|
+
link_url: 'https://example.com/3',
|
|
50
|
+
link_open_blank: false,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const defaultProps = {
|
|
55
|
+
pk: '1',
|
|
56
|
+
title: 'Test Slider',
|
|
57
|
+
slides: mockSlides,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
jest.resetAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('renders the slider with all slides', () => {
|
|
65
|
+
render(
|
|
66
|
+
<IntlProvider locale="en">
|
|
67
|
+
<Slider {...defaultProps} />
|
|
68
|
+
</IntlProvider>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Check if all slides are rendered
|
|
72
|
+
mockSlides.forEach((slide) => {
|
|
73
|
+
expect(screen.getByRole('img', { name: slide.title })).toBeInTheDocument();
|
|
74
|
+
// Check if the link is rendered
|
|
75
|
+
const link = screen.queryByRole('link', { name: slide.title });
|
|
76
|
+
if (slide.link_url) {
|
|
77
|
+
expect(link).toHaveAttribute('href', slide.link_url);
|
|
78
|
+
expect(link).toBeInTheDocument();
|
|
79
|
+
} else {
|
|
80
|
+
expect(link).not.toBeInTheDocument();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Check if navigation elements are present
|
|
85
|
+
expect(screen.getByRole('button', { name: 'Previous slide' })).toBeInTheDocument();
|
|
86
|
+
expect(screen.getByRole('button', { name: 'Next slide' })).toBeInTheDocument();
|
|
87
|
+
|
|
88
|
+
// Only the active slide content should be visible
|
|
89
|
+
const activeSlide = screen.getByRole('group', { name: /Slide 1:.*/ });
|
|
90
|
+
expect(activeSlide).toBeInTheDocument();
|
|
91
|
+
within(activeSlide).getByText(mockSlides[0].title);
|
|
92
|
+
within(activeSlide).getByText(mockSlides[0].content);
|
|
93
|
+
expect(screen.queryByRole('group', { name: /Slide 2:.*/ })).not.toBeInTheDocument();
|
|
94
|
+
expect(screen.queryByRole('group', { name: /Slide 3:.*/ })).not.toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('handles keyboard navigation', () => {
|
|
98
|
+
const mockEmblaApi = mockUseEmblaCarousel()[1]!;
|
|
99
|
+
render(
|
|
100
|
+
<IntlProvider locale="en">
|
|
101
|
+
<Slider {...defaultProps} />
|
|
102
|
+
</IntlProvider>,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const slider = screen.getByRole('button', { name: /test slider/i });
|
|
106
|
+
|
|
107
|
+
// Test arrow key navigation
|
|
108
|
+
fireEvent.keyDown(slider, { key: 'ArrowLeft' });
|
|
109
|
+
expect(mockEmblaApi.scrollPrev).toHaveBeenNthCalledWith(1);
|
|
110
|
+
|
|
111
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' });
|
|
112
|
+
expect(mockEmblaApi.scrollNext).toHaveBeenNthCalledWith(1);
|
|
113
|
+
|
|
114
|
+
// Test home/end key navigation
|
|
115
|
+
fireEvent.keyDown(slider, { key: 'Home' });
|
|
116
|
+
expect(mockEmblaApi.scrollTo).toHaveBeenNthCalledWith(1, 0);
|
|
117
|
+
|
|
118
|
+
fireEvent.keyDown(slider, { key: 'End' });
|
|
119
|
+
expect(mockEmblaApi.scrollTo).toHaveBeenNthCalledWith(2, mockSlides.length - 1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('provides accessible navigation controls', () => {
|
|
123
|
+
render(
|
|
124
|
+
<IntlProvider locale="en">
|
|
125
|
+
<Slider {...defaultProps} />
|
|
126
|
+
</IntlProvider>,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Check if navigation buttons are properly labeled
|
|
130
|
+
expect(screen.getByRole('button', { name: 'Previous slide' })).toHaveAttribute(
|
|
131
|
+
'aria-label',
|
|
132
|
+
'Previous slide',
|
|
133
|
+
);
|
|
134
|
+
expect(screen.getByRole('button', { name: 'Next slide' })).toHaveAttribute(
|
|
135
|
+
'aria-label',
|
|
136
|
+
'Next slide',
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Check if slider has proper ARIA attributes
|
|
140
|
+
const slider = screen.getByRole('button', { name: defaultProps.title });
|
|
141
|
+
expect(slider).toHaveAttribute('aria-roledescription', 'carousel');
|
|
142
|
+
const presentation = screen.getByRole('presentation');
|
|
143
|
+
expect(presentation).toBeInTheDocument();
|
|
144
|
+
expect(presentation).toHaveAttribute('aria-live', 'polite');
|
|
145
|
+
expect(presentation).toHaveAttribute('aria-atomic', 'true');
|
|
146
|
+
expect(presentation).toHaveTextContent(/Slide 1 of 3:.*/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('renders bullets navigation', () => {
|
|
150
|
+
render(
|
|
151
|
+
<IntlProvider locale="en">
|
|
152
|
+
<Slider {...defaultProps} />
|
|
153
|
+
</IntlProvider>,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const bullets = screen.getAllByRole('tab', { name: /Go to slide [1-3]{1}/ });
|
|
157
|
+
expect(bullets).toHaveLength(mockSlides.length);
|
|
158
|
+
|
|
159
|
+
expect(bullets[0]).toHaveAttribute('aria-selected', 'true');
|
|
160
|
+
expect(bullets[1]).toHaveAttribute('aria-selected', 'false');
|
|
161
|
+
expect(bullets[2]).toHaveAttribute('aria-selected', 'false');
|
|
162
|
+
|
|
163
|
+
// Click the second bullet
|
|
164
|
+
fireEvent.click(bullets[1]);
|
|
165
|
+
expect(mockUseEmblaCarousel()[1]!.scrollTo).toHaveBeenCalledWith(1);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import useEmblaCarousel from 'embla-carousel-react';
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
+
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
|
4
|
+
import { defineMessages, FormattedMessage } from 'react-intl';
|
|
5
|
+
import { Slide as SlideType } from './types';
|
|
6
|
+
import SlidePanel from './components/SlidePanel';
|
|
7
|
+
import Slideshow from './components/Slideshow';
|
|
8
|
+
|
|
9
|
+
const messages = defineMessages({
|
|
10
|
+
sliderSummary: {
|
|
11
|
+
id: 'widgets.Slider.sliderSummary',
|
|
12
|
+
defaultMessage: 'Slide {slideNumber} of {totalSlides}: {slideTitle}',
|
|
13
|
+
description: 'Aria live label which summarizes the slider state.',
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
type SliderProps = {
|
|
18
|
+
// eslint-disable-next-line react/no-unused-prop-types
|
|
19
|
+
pk: string;
|
|
20
|
+
title: string;
|
|
21
|
+
slides: readonly SlideType[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const Slider = ({ slides, title }: SliderProps) => {
|
|
25
|
+
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [WheelGesturesPlugin()]);
|
|
26
|
+
const [activeSlideIndex, setActiveSlideIndex] = useState(0);
|
|
27
|
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
28
|
+
|
|
29
|
+
const handleBulletClick = useCallback(
|
|
30
|
+
(index: number) => {
|
|
31
|
+
setIsTransitioning(true);
|
|
32
|
+
emblaApi?.scrollTo(index);
|
|
33
|
+
},
|
|
34
|
+
[emblaApi],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const handleKeyDown = useCallback(
|
|
38
|
+
(event: React.KeyboardEvent) => {
|
|
39
|
+
if (!emblaApi) return;
|
|
40
|
+
|
|
41
|
+
switch (event.key) {
|
|
42
|
+
case 'ArrowLeft':
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
emblaApi.scrollPrev();
|
|
45
|
+
break;
|
|
46
|
+
case 'ArrowRight':
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
emblaApi.scrollNext();
|
|
49
|
+
break;
|
|
50
|
+
case 'Home':
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
emblaApi.scrollTo(0);
|
|
53
|
+
break;
|
|
54
|
+
case 'End':
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
emblaApi.scrollTo(slides.length - 1);
|
|
57
|
+
break;
|
|
58
|
+
default:
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
[emblaApi, slides.length],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!emblaApi) return;
|
|
67
|
+
|
|
68
|
+
const handleSlidesChanged = (event: any) => {
|
|
69
|
+
setIsTransitioning(true);
|
|
70
|
+
setActiveSlideIndex(event.selectedScrollSnap());
|
|
71
|
+
};
|
|
72
|
+
emblaApi.on('select', handleSlidesChanged);
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
emblaApi.off('select', handleSlidesChanged);
|
|
76
|
+
};
|
|
77
|
+
}, [emblaApi]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
// Remove the transitioning class immediately after a transitioned render
|
|
81
|
+
setIsTransitioning(false);
|
|
82
|
+
}, [activeSlideIndex]);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className="slider"
|
|
87
|
+
ref={emblaRef}
|
|
88
|
+
aria-roledescription="carousel"
|
|
89
|
+
aria-label={title}
|
|
90
|
+
role="button"
|
|
91
|
+
tabIndex={0}
|
|
92
|
+
onKeyDown={handleKeyDown}
|
|
93
|
+
>
|
|
94
|
+
<Slideshow
|
|
95
|
+
slides={slides}
|
|
96
|
+
onNextSlide={() => emblaApi?.scrollNext()}
|
|
97
|
+
onPreviousSlide={() => emblaApi?.scrollPrev()}
|
|
98
|
+
/>
|
|
99
|
+
<SlidePanel
|
|
100
|
+
slides={slides}
|
|
101
|
+
activeSlideIndex={activeSlideIndex}
|
|
102
|
+
onBulletClick={handleBulletClick}
|
|
103
|
+
isTransitioning={isTransitioning}
|
|
104
|
+
/>
|
|
105
|
+
<span className="offscreen" role="presentation" aria-live="polite" aria-atomic="true">
|
|
106
|
+
<FormattedMessage
|
|
107
|
+
{...messages.sliderSummary}
|
|
108
|
+
values={{
|
|
109
|
+
slideNumber: activeSlideIndex + 1,
|
|
110
|
+
totalSlides: slides.length,
|
|
111
|
+
slideTitle: slides[activeSlideIndex].title,
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default Slider;
|
package/js/widgets/index.tsx
CHANGED
|
@@ -18,6 +18,7 @@ const Search = lazy(() => import('widgets/Search'));
|
|
|
18
18
|
const SearchSuggestField = lazy(() => import('widgets/SearchSuggestField'));
|
|
19
19
|
const SyllabusCourseRunsList = lazy(() => import('widgets/SyllabusCourseRunsList'));
|
|
20
20
|
const UserLogin = lazy(() => import('widgets/UserLogin'));
|
|
21
|
+
const Slider = lazy(() => import('widgets/Slider'));
|
|
21
22
|
|
|
22
23
|
// List the top-level components that can be directly called from the Django templates in an interface
|
|
23
24
|
// for type-safety when we call them. This will let us use the props for any top-level component in a
|
|
@@ -31,6 +32,7 @@ interface ComponentLibrary {
|
|
|
31
32
|
SearchSuggestField: typeof SearchSuggestField;
|
|
32
33
|
SyllabusCourseRunsList: typeof SyllabusCourseRunsList;
|
|
33
34
|
UserLogin: typeof UserLogin;
|
|
35
|
+
Slider: typeof Slider;
|
|
34
36
|
}
|
|
35
37
|
// Actually create the component map that we'll use below to access our component classes
|
|
36
38
|
const componentLibrary: ComponentLibrary = {
|
|
@@ -42,6 +44,7 @@ const componentLibrary: ComponentLibrary = {
|
|
|
42
44
|
SearchSuggestField,
|
|
43
45
|
SyllabusCourseRunsList,
|
|
44
46
|
UserLogin,
|
|
47
|
+
Slider,
|
|
45
48
|
};
|
|
46
49
|
// Type guard: ensures a given string (candidate) is indeed a proper key of the componentLibrary with a corresponding
|
|
47
50
|
// component. This is a runtime check but it allows TS to check the component prop types at compile time
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "richie-education",
|
|
3
|
-
"version": "2.34.1-
|
|
3
|
+
"version": "2.34.1-dev51",
|
|
4
4
|
"description": "A CMS to build learning portals for Open Education",
|
|
5
5
|
"main": "sandbox/manage.py",
|
|
6
6
|
"scripts": {
|
|
@@ -90,6 +90,8 @@
|
|
|
90
90
|
"cljs-merge": "1.1.1",
|
|
91
91
|
"core-js": "3.41.0",
|
|
92
92
|
"downshift": "9.0.9",
|
|
93
|
+
"embla-carousel-react": "8.5.2",
|
|
94
|
+
"embla-carousel-wheel-gestures": "8.0.1",
|
|
93
95
|
"eslint": ">=8.57.0 <9",
|
|
94
96
|
"eslint-config-airbnb": "19.0.4",
|
|
95
97
|
"eslint-config-airbnb-typescript": "18.0.0",
|
package/scss/colors/_theme.scss
CHANGED
|
@@ -160,10 +160,12 @@ $r-theme: (
|
|
|
160
160
|
secondary-color: r-color('white'),
|
|
161
161
|
),
|
|
162
162
|
slider-plugin: (
|
|
163
|
-
arrows-color: r-color('white'),
|
|
164
|
-
arrows-
|
|
165
|
-
|
|
166
|
-
index-
|
|
163
|
+
arrows-fill-color: r-color('white'),
|
|
164
|
+
arrows-stroke-color: r-color('black'),
|
|
165
|
+
arrows-stroke-width: 0.5px,
|
|
166
|
+
index-color: r-color('battleship-grey'),
|
|
167
|
+
index-hover-color: r-color('indianred3'),
|
|
168
|
+
index-active-color: r-color('firebrick6'),
|
|
167
169
|
),
|
|
168
170
|
blogpost-glimpse: (
|
|
169
171
|
card-background: r-color('white'),
|
|
@@ -1,142 +1,208 @@
|
|
|
1
|
-
$r-slider-
|
|
1
|
+
$r-slider-slide-height: clamp(400px, 50vh, 600px) !default;
|
|
2
|
+
$r-slider-title-fontsize: $h2-font-size !default;
|
|
2
3
|
$r-slider-title-fontweight: $font-weight-bold !default;
|
|
3
4
|
$r-slider-title-fontfamily: $headings-font-family !default;
|
|
4
|
-
$r-slider-content-fontsize:
|
|
5
|
+
$r-slider-content-fontsize: 1rem !default;
|
|
5
6
|
$r-slider-content-line-height: 1.1 !default;
|
|
7
|
+
$r-slider-content-line-clamp: 4 !default;
|
|
8
|
+
.richie-react--slider {
|
|
9
|
+
min-height: $r-slider-slide-height;
|
|
10
|
+
}
|
|
6
11
|
|
|
7
12
|
.slider {
|
|
13
|
+
overflow: hidden;
|
|
8
14
|
position: relative;
|
|
9
|
-
|
|
10
|
-
// Reserved space for slide indexes
|
|
11
|
-
padding-bottom: 1.75rem;
|
|
15
|
+
}
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
.slider__slideshow {
|
|
18
|
+
display: flex;
|
|
19
|
+
height: $r-slider-slide-height;
|
|
20
|
+
position: relative;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.slider__slide {
|
|
24
|
+
flex: 0 0 100%;
|
|
25
|
+
min-width: 0;
|
|
26
|
+
|
|
27
|
+
img {
|
|
28
|
+
width: 100%;
|
|
29
|
+
height: 100%;
|
|
30
|
+
object-fit: cover;
|
|
16
31
|
}
|
|
32
|
+
}
|
|
17
33
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
.slider__slideshow-overlay {
|
|
35
|
+
@include make-container();
|
|
36
|
+
@include make-container-max-widths();
|
|
37
|
+
height: clamp(400px, 50vh, 600px);
|
|
38
|
+
position: absolute;
|
|
39
|
+
top: 0;
|
|
40
|
+
left: 50%;
|
|
41
|
+
transform: translateX(-50%);
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
justify-content: space-between;
|
|
45
|
+
pointer-events: none;
|
|
46
|
+
|
|
47
|
+
// Re-enable pointer events for children
|
|
48
|
+
& > * {
|
|
49
|
+
pointer-events: auto;
|
|
25
50
|
}
|
|
51
|
+
}
|
|
26
52
|
|
|
27
|
-
|
|
28
|
-
|
|
53
|
+
.slider__navigation-button {
|
|
54
|
+
@include button-reset-style();
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
margin: 0;
|
|
57
|
+
height: 100%;
|
|
58
|
+
padding: 0;
|
|
59
|
+
position: relative;
|
|
60
|
+
display: inline-block;
|
|
61
|
+
text-align: right;
|
|
62
|
+
|
|
63
|
+
$width: 50vw;
|
|
64
|
+
&:before {
|
|
65
|
+
content: '';
|
|
66
|
+
display: block;
|
|
67
|
+
width: $width;
|
|
68
|
+
height: 100%;
|
|
29
69
|
position: absolute;
|
|
30
|
-
top:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
background: none;
|
|
34
|
-
border: 0;
|
|
35
|
-
|
|
36
|
-
svg {
|
|
37
|
-
width: 5rem;
|
|
38
|
-
height: 5rem;
|
|
39
|
-
color: r-theme-val(slider-plugin, arrows-color);
|
|
40
|
-
}
|
|
70
|
+
top: 0;
|
|
71
|
+
z-index: -1;
|
|
72
|
+
}
|
|
41
73
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
74
|
+
&:first-child {
|
|
75
|
+
--hover-offet-x: -10%;
|
|
76
|
+
&:before {
|
|
77
|
+
left: $width * -1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
&:last-child {
|
|
81
|
+
--hover-offet-x: 10%;
|
|
82
|
+
&:before {
|
|
83
|
+
right: $width * -1;
|
|
46
84
|
}
|
|
47
85
|
}
|
|
48
86
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
right: 0;
|
|
87
|
+
&:hover > .icon {
|
|
88
|
+
transform: scale(1.2) translateX(var(--hover-offet-x));
|
|
52
89
|
}
|
|
53
90
|
|
|
54
|
-
&
|
|
55
|
-
|
|
56
|
-
|
|
91
|
+
& > .icon {
|
|
92
|
+
transition: transform 300ms $r-ease-out;
|
|
93
|
+
color: r-theme-val(slider-plugin, arrows-fill-color);
|
|
94
|
+
stroke: r-theme-val(slider-plugin, arrows-stroke-color);
|
|
95
|
+
stroke-width: r-theme-val(slider-plugin, arrows-stroke-width);
|
|
96
|
+
height: 4rem;
|
|
97
|
+
width: 4rem;
|
|
57
98
|
}
|
|
99
|
+
}
|
|
58
100
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
101
|
+
.slider__panel {
|
|
102
|
+
@include make-container();
|
|
103
|
+
@include make-container-max-widths();
|
|
104
|
+
|
|
105
|
+
display: flex;
|
|
106
|
+
flex-direction: column-reverse;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.slider__bullet {
|
|
110
|
+
&-list {
|
|
66
111
|
display: flex;
|
|
67
|
-
|
|
112
|
+
flex-direction: row;
|
|
113
|
+
gap: 6px;
|
|
68
114
|
align-items: center;
|
|
115
|
+
justify-content: flex-end;
|
|
69
116
|
}
|
|
70
117
|
|
|
71
|
-
|
|
72
|
-
@include
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
118
|
+
&-item {
|
|
119
|
+
@include button-reset-style();
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
width: 2rem;
|
|
122
|
+
transform-origin: left center;
|
|
123
|
+
padding-block: 1rem;
|
|
124
|
+
position: relative;
|
|
125
|
+
display: block;
|
|
77
126
|
|
|
78
|
-
|
|
127
|
+
&:before {
|
|
79
128
|
content: '';
|
|
80
129
|
display: block;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
border:
|
|
130
|
+
width: 100%;
|
|
131
|
+
height: 3px;
|
|
132
|
+
border-radius: 50vw;
|
|
133
|
+
background-color: r-theme-val(slider-plugin, index-color);
|
|
134
|
+
position: absolute;
|
|
135
|
+
transform-origin: left center;
|
|
136
|
+
transition: height 400ms $r-ease-out;
|
|
137
|
+
translate: 0 -50%;
|
|
138
|
+
transform: scaleY(1);
|
|
84
139
|
}
|
|
85
140
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
height: 0.5rem;
|
|
91
|
-
}
|
|
141
|
+
&:hover:before,
|
|
142
|
+
&:focus:before,
|
|
143
|
+
&--active:before {
|
|
144
|
+
height: 7px;
|
|
92
145
|
}
|
|
93
146
|
|
|
94
|
-
&:hover
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
147
|
+
&:hover:before,
|
|
148
|
+
&:focus:before {
|
|
149
|
+
background-color: r-theme-val(slider-plugin, index-hover-color);
|
|
98
150
|
}
|
|
99
|
-
}
|
|
100
151
|
|
|
101
|
-
|
|
102
|
-
|
|
152
|
+
&--active:before {
|
|
153
|
+
background-color: r-theme-val(slider-plugin, index-active-color);
|
|
154
|
+
}
|
|
103
155
|
}
|
|
104
156
|
}
|
|
105
157
|
|
|
106
|
-
.
|
|
107
|
-
|
|
108
|
-
display: block;
|
|
109
|
-
color: inherit;
|
|
110
|
-
text-decoration: none;
|
|
111
|
-
|
|
112
|
-
// Disable any hover event on content since it can be in a link
|
|
113
|
-
&:hover,
|
|
114
|
-
*:hover {
|
|
115
|
-
color: inherit !important;
|
|
116
|
-
text-decoration: none !important;
|
|
117
|
-
}
|
|
158
|
+
.slide__content {
|
|
159
|
+
max-width: 680px;
|
|
118
160
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
161
|
+
&--transitioning {
|
|
162
|
+
.slide__title > span,
|
|
163
|
+
.slide__description {
|
|
164
|
+
transition: inherit;
|
|
165
|
+
}
|
|
124
166
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
167
|
+
.slide__title > span {
|
|
168
|
+
transform: translateY(150%);
|
|
169
|
+
opacity: 0;
|
|
170
|
+
}
|
|
130
171
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
font-weight: $r-slider-title-fontweight;
|
|
135
|
-
margin: 0 0 0.5rem 0;
|
|
172
|
+
.slide__description {
|
|
173
|
+
opacity: 0;
|
|
174
|
+
}
|
|
136
175
|
}
|
|
176
|
+
}
|
|
137
177
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
178
|
+
.slide__title {
|
|
179
|
+
overflow: hidden;
|
|
180
|
+
display: inline-block;
|
|
181
|
+
font-size: $r-slider-title-fontsize;
|
|
182
|
+
font-weight: $r-slider-title-fontweight;
|
|
183
|
+
font-family: $r-slider-title-fontfamily;
|
|
184
|
+
|
|
185
|
+
& > span {
|
|
186
|
+
display: inline-block;
|
|
187
|
+
transform: translateY(0%);
|
|
188
|
+
opacity: 1;
|
|
189
|
+
transition-property: transform, opacity;
|
|
190
|
+
transition-duration: 0.8s, 0.4s;
|
|
191
|
+
transition-delay: 0s, 0.1s;
|
|
192
|
+
transition-timing-function: $r-ease-out, $r-ease-in;
|
|
141
193
|
}
|
|
142
194
|
}
|
|
195
|
+
|
|
196
|
+
.slide__description {
|
|
197
|
+
-webkit-box-orient: vertical;
|
|
198
|
+
-webkit-line-clamp: $r-slider-content-line-clamp;
|
|
199
|
+
line-clamp: $r-slider-content-line-clamp;
|
|
200
|
+
display: -webkit-box;
|
|
201
|
+
overflow: hidden;
|
|
202
|
+
min-height: $r-slider-content-line-clamp *
|
|
203
|
+
($r-slider-content-fontsize * $r-slider-content-line-height);
|
|
204
|
+
opacity: 1;
|
|
205
|
+
transition: opacity 0.3s 0.4s $r-ease-in;
|
|
206
|
+
font-size: $r-slider-content-fontsize;
|
|
207
|
+
line-height: $r-slider-content-line-height;
|
|
208
|
+
}
|