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.
@@ -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 refDate = new Date();
138
- refDate.setMonth(refDate.getMonth() + 1);
139
- refDate.setDate(1);
140
- const futureLessThan3Months = faker.date.future({ years: 2.99 / 12, refDate });
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: futureLessThan3Months.getMonth() + 1,
143
- expiration_year: futureLessThan3Months.getFullYear(),
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
- (futureLessThan3Months.getMonth() + 1).toLocaleString(undefined, {
155
+ (expirationDate.getMonth() + 1).toLocaleString(undefined, {
155
156
  minimumIntegerDigits: 2,
156
157
  }) +
157
158
  '/' +
158
- futureLessThan3Months.getFullYear(),
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;
@@ -0,0 +1,8 @@
1
+ export type Slide = {
2
+ pk: string;
3
+ title: string;
4
+ image: string;
5
+ content: string;
6
+ link_url: string;
7
+ link_open_blank: boolean;
8
+ };
@@ -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-dev47",
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",
@@ -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-hover-color: r-color('firebrick6'),
165
- index-color: r-color('charcoal'),
166
- index-hover-color: r-color('black'),
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-title-fontsize: $h1-font-size !default;
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: $h4-font-size !default;
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
- width: 100%;
10
- // Reserved space for slide indexes
11
- padding-bottom: 1.75rem;
15
+ }
12
16
 
13
- &__items {
14
- display: flex;
15
- overflow-x: hidden;
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
- &__tools {
19
- @include make-container-max-widths();
20
- margin: 0 auto;
21
- padding: 0;
22
- display: flex;
23
- justify-content: center;
24
- align-content: center;
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
- &__next,
28
- &__previous {
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: 25%;
31
- left: 0;
32
- right: 0;
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
- &:hover {
43
- svg {
44
- color: r-theme-val(slider-plugin, arrows-hover-color);
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
- &__next {
50
- left: auto;
51
- right: 0;
87
+ &:hover > .icon {
88
+ transform: scale(1.2) translateX(var(--hover-offet-x));
52
89
  }
53
90
 
54
- &__previous {
55
- left: 0;
56
- right: auto;
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
- &__indexes {
60
- @include make-container-max-widths();
61
- margin: 0 auto;
62
- width: 100%;
63
- padding: 0.5rem;
64
- position: absolute;
65
- bottom: 0;
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
- justify-content: flex-end;
112
+ flex-direction: row;
113
+ gap: 6px;
68
114
  align-items: center;
115
+ justify-content: flex-end;
69
116
  }
70
117
 
71
- &__index {
72
- @include sv-flex(1, 0, 1.9rem);
73
- padding: 0;
74
- height: 1rem;
75
- background: transparent;
76
- border: 0;
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
- &::before {
127
+ &:before {
79
128
  content: '';
80
129
  display: block;
81
- height: 0.2rem;
82
- background: r-theme-val(slider-plugin, index-color);
83
- border: 0;
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
- &--active {
87
- pointer-events: none;
88
-
89
- &::before {
90
- height: 0.5rem;
91
- }
141
+ &:hover:before,
142
+ &:focus:before,
143
+ &--active:before {
144
+ height: 7px;
92
145
  }
93
146
 
94
- &:hover {
95
- &::before {
96
- background: r-theme-val(slider-plugin, index-hover-color);
97
- }
147
+ &:hover:before,
148
+ &:focus:before {
149
+ background-color: r-theme-val(slider-plugin, index-hover-color);
98
150
  }
99
- }
100
151
 
101
- &__index + &__index {
102
- margin-left: 0.3rem;
152
+ &--active:before {
153
+ background-color: r-theme-val(slider-plugin, index-active-color);
154
+ }
103
155
  }
104
156
  }
105
157
 
106
- .slider-item {
107
- @include sv-flex(1, 0, 100%);
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
- &__image {
120
- display: block;
121
- margin: 0 0 1rem 0;
122
- width: 100%;
123
- }
161
+ &--transitioning {
162
+ .slide__title > span,
163
+ .slide__description {
164
+ transition: inherit;
165
+ }
124
166
 
125
- &__container {
126
- @include make-container-max-widths();
127
- position: relative;
128
- margin: 0 auto;
129
- }
167
+ .slide__title > span {
168
+ transform: translateY(150%);
169
+ opacity: 0;
170
+ }
130
171
 
131
- &__title {
132
- @include font-size($r-slider-title-fontsize);
133
- font-family: $r-slider-title-fontfamily;
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
- &__content {
139
- @include font-size($r-slider-content-fontsize);
140
- line-height: $r-slider-content-line-height;
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
+ }