richie-education 2.34.1-dev5 → 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.
Files changed (33) hide show
  1. package/js/components/ContractFrame/AbstractContractFrame.spec.tsx +1 -1
  2. package/js/components/CourseGlimpse/CourseGlimpseFooter.tsx +84 -13
  3. package/js/components/CourseGlimpse/index.spec.tsx +80 -5
  4. package/js/components/CourseGlimpse/index.tsx +92 -76
  5. package/js/components/CourseGlimpse/utils.ts +31 -1
  6. package/js/components/Icon/index.tsx +7 -0
  7. package/js/components/OpenEdxFullNameForm/index.spec.tsx +17 -7
  8. package/js/components/OpenEdxFullNameForm/index.tsx +13 -16
  9. package/js/components/SaleTunnel/index.full-process.spec.tsx +1 -1
  10. package/js/pages/DashboardCreditCardsManagement/index.spec.tsx +9 -8
  11. package/js/types/Course.ts +18 -0
  12. package/js/types/index.ts +5 -0
  13. package/js/utils/test/expectAlert.ts +63 -0
  14. package/js/utils/test/factories/richie.ts +31 -1
  15. package/js/widgets/Slider/components/Slide.tsx +20 -0
  16. package/js/widgets/Slider/components/SlidePanel.tsx +83 -0
  17. package/js/widgets/Slider/components/Slideshow.tsx +58 -0
  18. package/js/widgets/Slider/index.spec.tsx +167 -0
  19. package/js/widgets/Slider/index.tsx +119 -0
  20. package/js/widgets/Slider/types/index.ts +8 -0
  21. package/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRun/index.tsx +107 -0
  22. package/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRunCompacted/index.tsx +107 -0
  23. package/js/widgets/SyllabusCourseRunsList/index.spec.tsx +450 -5
  24. package/js/widgets/index.tsx +3 -0
  25. package/package.json +45 -43
  26. package/scss/colors/_theme.scss +26 -7
  27. package/scss/components/_header.scss +108 -14
  28. package/scss/components/_subheader.scss +35 -0
  29. package/scss/components/templates/courses/cms/_program_detail.scss +71 -0
  30. package/scss/components/templates/richie/slider/_slider.scss +165 -99
  31. package/scss/objects/_course_glimpses.scss +103 -8
  32. package/scss/objects/_selector.scss +1 -0
  33. package/scss/settings/_variables.scss +4 -0
@@ -1,4 +1,4 @@
1
- import { ButtonElement, Input } from '@openfun/cunningham-react';
1
+ import { ButtonElement, Input, Alert, VariantType } from '@openfun/cunningham-react';
2
2
  import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
3
3
  import { FormProvider, useForm } from 'react-hook-form';
4
4
  import * as Yup from 'yup';
@@ -8,7 +8,6 @@ import { useSession } from 'contexts/SessionContext';
8
8
  import useOpenEdxProfile from 'hooks/useOpenEdxProfile';
9
9
  import Form, { getLocalizedCunninghamErrorProp } from 'components/Form';
10
10
  import { Spinner } from 'components/Spinner';
11
- import Banner, { BannerType } from 'components/Banner';
12
11
  import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
13
12
 
14
13
  const messages = defineMessages({
@@ -19,14 +18,15 @@ const messages = defineMessages({
19
18
  },
20
19
  fullNameInputLabel: {
21
20
  id: 'components.OpenEdxFullNameForm.fullNameInputLabel',
22
- description: 'Label of "fullName" field of the openEdx full name form',
23
- defaultMessage: 'Full name',
21
+ description: 'Label of "First name and last name" field of the openEdx full name form',
22
+ defaultMessage: 'First name and last name',
24
23
  },
25
24
  fullNameInputDescription: {
26
25
  id: 'components.OpenEdxFullNameForm.fullNameInputDescription',
27
- description: 'Descripiton on the "fullName" field of the openEdx full name form.',
26
+ description:
27
+ 'Description about the "First name and last name" field of the openEdx full name form.',
28
28
  defaultMessage:
29
- 'Please check that your fullname is correct. It will be used on official document (e.g: certificate)',
29
+ 'Please check that your first name and last name are correct. They will be used on official document (e.g: certificate, contract, etc.)',
30
30
  },
31
31
  submitButtonLabel: {
32
32
  id: 'components.OpenEdxFullNameForm.submitButtonLabel',
@@ -128,27 +128,24 @@ const OpenEdxFullNameForm = () => {
128
128
 
129
129
  if (error) {
130
130
  // display get error message
131
- return <Banner type={BannerType.ERROR} message={error} />;
131
+ return <Alert type={VariantType.ERROR}>{error}</Alert>;
132
132
  }
133
133
 
134
134
  return (
135
135
  <FormProvider {...form}>
136
136
  <Form name="openedx-fullname-form" noValidate>
137
+ <Alert type={formState.errors.name?.message ? VariantType.ERROR : VariantType.WARNING}>
138
+ <FormattedMessage {...messages.fullNameInputDescription} />
139
+ </Alert>
137
140
  <Input
138
141
  {...register('name')}
139
- className="form-field"
142
+ className="form-field mt-s"
140
143
  required
141
144
  fullWidth
142
145
  label={intl.formatMessage(messages.fullNameInputLabel)}
143
146
  value={formState.defaultValues?.name}
144
- state={
145
- error || (formState.errors.name && formState.errors.name.message) ? 'error' : 'default'
146
- }
147
- text={
148
- error ||
149
- getLocalizedCunninghamErrorProp(intl, formState.errors.name?.message).text ||
150
- intl.formatMessage(messages.fullNameInputDescription)
151
- }
147
+ state={error || formState.errors.name?.message ? 'error' : 'default'}
148
+ text={error || getLocalizedCunninghamErrorProp(intl, formState.errors.name?.message).text}
152
149
  />
153
150
  </Form>
154
151
  </FormProvider>
@@ -184,7 +184,7 @@ describe('SaleTunnel', () => {
184
184
  */
185
185
  screen.getByText('Information');
186
186
 
187
- const nameInput = screen.getByLabelText('Full name');
187
+ const nameInput = screen.getByLabelText('First name and last name');
188
188
  await user.type(nameInput, 'John Doe');
189
189
 
190
190
  /**
@@ -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');
@@ -3,6 +3,19 @@ import { Resource } from 'types/Resource';
3
3
  import { Nullable } from 'types/utils';
4
4
  import { CourseListItem as JoanieCourse } from 'types/Joanie';
5
5
 
6
+ export enum CourseOffer {
7
+ PAID = 'paid',
8
+ FREE = 'free',
9
+ PARTIALLY_FREE = 'partially_free',
10
+ SUBSCRIPTION = 'subscription',
11
+ }
12
+
13
+ export enum CourseCertificateOffer {
14
+ PAID = 'paid',
15
+ FREE = 'free',
16
+ SUBSCRIPTION = 'subscription',
17
+ }
18
+
6
19
  export interface Course extends Resource {
7
20
  absolute_url: string;
8
21
  categories: string[];
@@ -29,6 +42,11 @@ export interface Course extends Resource {
29
42
  }>;
30
43
  organizations: string[];
31
44
  state: CourseState;
45
+ certificate_offer: Nullable<CourseCertificateOffer>;
46
+ offer: Nullable<CourseOffer>;
47
+ certificate_price: Nullable<number>;
48
+ price: Nullable<number>;
49
+ price_currency: string;
32
50
  }
33
51
 
34
52
  export function isRichieCourse(course: Course | JoanieCourse): course is Course {
package/js/types/index.ts CHANGED
@@ -35,6 +35,11 @@ export interface CourseRun {
35
35
  title?: string;
36
36
  snapshot?: string;
37
37
  display_mode: CourseRunDisplayMode;
38
+ price?: number;
39
+ price_currency?: string;
40
+ offer?: string;
41
+ certificate_price?: number;
42
+ certificate_offer?: string;
38
43
  }
39
44
 
40
45
  export enum Priority {
@@ -0,0 +1,63 @@
1
+ import { waitFor } from '@testing-library/react';
2
+ import { VariantType } from '@openfun/cunningham-react';
3
+
4
+ export const expectAlertError = (message: string, rootElement: ParentNode = document) => {
5
+ return expectAlert(VariantType.ERROR, message, rootElement);
6
+ };
7
+
8
+ export const expectAlertInfo = (message: string, rootElement: ParentNode = document) => {
9
+ return expectAlert(VariantType.INFO, message, rootElement);
10
+ };
11
+
12
+ export const expectAlertSuccess = (message: string, rootElement: ParentNode = document) => {
13
+ return expectAlert(VariantType.SUCCESS, message, rootElement);
14
+ };
15
+
16
+ export const expectAlertWarning = (message: string, rootElement: ParentNode = document) => {
17
+ return expectAlert(VariantType.WARNING, message, rootElement);
18
+ };
19
+
20
+ export const expectAlert = (
21
+ type: VariantType,
22
+ message: string,
23
+ rootElement: ParentNode = document,
24
+ ) => {
25
+ return waitFor(async () => {
26
+ // Cunningham Alert has a class with the alert variant type
27
+ const alert = rootElement.querySelector(`.c__alert--${type}`) as HTMLElement;
28
+ expect(alert).not.toBeNull();
29
+ expect(alert).toHaveTextContent(message);
30
+ });
31
+ };
32
+
33
+ export const expectNoAlert = (
34
+ type: VariantType,
35
+ message: string,
36
+ rootElement: ParentNode = document,
37
+ ) => {
38
+ return waitFor(() => {
39
+ // Check that no alert exists with this message and type
40
+ const alerts = rootElement.querySelectorAll('.c__alert');
41
+ const matchingAlert = Array.from(alerts).find(
42
+ (alert) =>
43
+ alert.classList.contains(`c__alert--${type}`) && alert.textContent?.includes(message),
44
+ );
45
+ expect(matchingAlert).toBeUndefined();
46
+ });
47
+ };
48
+
49
+ export const expectNoAlertError = (message: string, rootElement: ParentNode = document) => {
50
+ return expectNoAlert(VariantType.ERROR, message, rootElement);
51
+ };
52
+
53
+ export const expectNoAlertInfo = (message: string, rootElement: ParentNode = document) => {
54
+ return expectNoAlert(VariantType.INFO, message, rootElement);
55
+ };
56
+
57
+ export const expectNoAlertSuccess = (message: string, rootElement: ParentNode = document) => {
58
+ return expectNoAlert(VariantType.SUCCESS, message, rootElement);
59
+ };
60
+
61
+ export const expectNoAlertWarning = (message: string, rootElement: ParentNode = document) => {
62
+ return expectNoAlert(VariantType.WARNING, message, rootElement);
63
+ };
@@ -10,7 +10,7 @@ import {
10
10
  PacedCourse,
11
11
  Priority,
12
12
  } from 'types';
13
- import { Course } from 'types/Course';
13
+ import { CourseCertificateOffer, Course, CourseOffer } from 'types/Course';
14
14
  import { FactoryHelper } from 'utils/test/factories/helper';
15
15
  import { factory } from './factories';
16
16
 
@@ -45,7 +45,27 @@ export const CourseStateFutureOpenFactory = factory<CourseState>(() => {
45
45
  };
46
46
  });
47
47
 
48
+ enum OfferType {
49
+ PAID = 'PAID',
50
+ FREE = 'FREE',
51
+ PARTIALLY_FREE = 'PARTIALLY_FREE',
52
+ SUBSCRIPTION = 'SUBSCRIPTION',
53
+ }
54
+
48
55
  export const CourseRunFactory = factory<CourseRun>(() => {
56
+ const offerValues = Object.values(OfferType);
57
+ const offer = offerValues[Math.floor(Math.random() * offerValues.length)];
58
+ const certificateOfferValues = [OfferType.PAID, OfferType.FREE, OfferType.SUBSCRIPTION];
59
+ const certificateOffer =
60
+ certificateOfferValues[Math.floor(Math.random() * certificateOfferValues.length)];
61
+ const currency = faker.finance.currency().code;
62
+ const price = [OfferType.FREE, OfferType.PARTIALLY_FREE].includes(offer)
63
+ ? 0
64
+ : parseFloat(faker.finance.amount({ min: 1, max: 100, symbol: currency, autoFormat: true }));
65
+ const certificatePrice =
66
+ certificateOffer === OfferType.FREE
67
+ ? 0
68
+ : parseFloat(faker.finance.amount({ min: 1, max: 100, symbol: currency, autoFormat: true }));
49
69
  return {
50
70
  id: faker.number.int(),
51
71
  resource_link: FactoryHelper.unique(faker.internet.url),
@@ -58,6 +78,11 @@ export const CourseRunFactory = factory<CourseRun>(() => {
58
78
  dashboard_link: null,
59
79
  title: faker.lorem.sentence(3),
60
80
  display_mode: CourseRunDisplayMode.DETAILED,
81
+ price,
82
+ price_currency: currency,
83
+ offer,
84
+ certificate_price: certificatePrice,
85
+ certificate_offer: certificateOffer,
61
86
  };
62
87
  });
63
88
 
@@ -214,5 +239,10 @@ export const CourseLightFactory = factory<Course>(() => {
214
239
  },
215
240
  organizations: [organizationName],
216
241
  state: CourseStateFactory().one(),
242
+ certificate_offer: CourseCertificateOffer.FREE,
243
+ offer: CourseOffer.FREE,
244
+ certificate_price: null,
245
+ price: null,
246
+ price_currency: 'EUR',
217
247
  };
218
248
  });
@@ -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
+ });