Skip to content

Commit b615719

Browse files
committed
feat(carousel): navigate with dots
HH-380.
1 parent 4469706 commit b615719

7 files changed

Lines changed: 184 additions & 11 deletions

File tree

src/core/carousel/Carousel.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default {
1919
loadMoreButtonLabelText: { control: { type: 'text' } },
2020
hasMore: { control: { type: 'boolean' } },
2121
loading: { control: { type: 'boolean' } },
22+
navigateWithDots: { control: { type: 'boolean' } },
2223
},
2324
} as Meta<typeof Carousel>;
2425

src/core/carousel/carousel.module.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@
145145
height: 12px;
146146
margin: 0 2px;
147147

148+
&.button {
149+
cursor: pointer;
150+
}
151+
148152
&.selected {
149153
background-color: var(--color-black-40);
150154
}

src/core/carousel/components/CarouselSliderDot.tsx

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,57 @@ import classNames from 'classnames';
44
import styles from '../carousel.module.scss';
55
import { getSlideDotKey } from '../utils/utils';
66
import { useCarouselContext } from '../context/CarouselContext';
7+
import { useTranslationWithFallback } from '../../translation/useTranslationWithFallback';
8+
9+
function CarouselSlideDot({ slideIndex }: { slideIndex: number }) {
10+
const { currentSlide, handleUpdateSlideProps, navigateWithDots } =
11+
useCarouselContext();
12+
13+
const { t } = useTranslationWithFallback();
14+
15+
const onClickHandler = (slideNumber: number): void => {
16+
if (slideNumber === currentSlide) {
17+
return;
18+
}
19+
if (slideNumber > currentSlide) {
20+
handleUpdateSlideProps(slideNumber);
21+
} else {
22+
handleUpdateSlideProps(-slideNumber);
23+
}
24+
};
25+
const dotButtonAriaLabel = t('carouselSliderDotNavLabelText').replace(
26+
'{slideNumber}',
27+
String(slideIndex + 1),
28+
);
29+
30+
return (
31+
<button
32+
className={classNames(styles.dot, {
33+
[styles.selected]: slideIndex === currentSlide,
34+
[styles.button]: navigateWithDots,
35+
})}
36+
onClick={() => onClickHandler(slideIndex)}
37+
tabIndex={0}
38+
type="button"
39+
aria-label={dotButtonAriaLabel}
40+
disabled={!navigateWithDots}
41+
/>
42+
);
43+
}
744

845
export function CarouselSlideDots() {
9-
const { numberOfSlides, currentSlide } = useCarouselContext();
46+
const { numberOfSlides, navigateWithDots } = useCarouselContext();
1047

1148
return (
12-
<div className={styles.dotsContainer}>
13-
{[...Array(numberOfSlides)].map((e, i) => (
14-
<div
15-
key={getSlideDotKey(e, i)}
16-
className={classNames(
17-
styles.dot,
18-
i === currentSlide && styles.selected,
19-
)}
49+
<div
50+
className={styles.dotsContainer}
51+
role="navigation"
52+
aria-hidden={!navigateWithDots}
53+
>
54+
{[...Array(numberOfSlides)].map((entry, slideIndex) => (
55+
<CarouselSlideDot
56+
key={getSlideDotKey(entry, slideIndex)}
57+
slideIndex={slideIndex}
2058
/>
2159
))}
2260
</div>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
5+
import { CarouselSlideDots } from '../CarouselSliderDot';
6+
import { CarouselContext } from '../../context/CarouselContext';
7+
import type { CarouselContextType } from '../../types';
8+
import { initialCarouselContextValues } from '../../constants';
9+
10+
jest.mock('../../../translation/useTranslationWithFallback', () => ({
11+
useTranslationWithFallback: () => ({
12+
t: (key: string) => {
13+
if (key === 'carouselSliderDotNavLabelText') {
14+
return 'Jump to slide {slideNumber}';
15+
}
16+
return key;
17+
},
18+
}),
19+
}));
20+
21+
// A helper function to render components with a mock context
22+
const renderWithContext = (
23+
ui: React.ReactElement,
24+
providerProps: Partial<CarouselContextType>,
25+
) =>
26+
render(
27+
<CarouselContext.Provider
28+
value={{
29+
...initialCarouselContextValues,
30+
// Dummy functions for context fields that require them
31+
setTransformValue: jest.fn(),
32+
setNumberOfSlides: jest.fn(),
33+
setItemsPerSlide: jest.fn(),
34+
setCurrentSlide: jest.fn(),
35+
setWidth: jest.fn(),
36+
...providerProps,
37+
}}
38+
>
39+
{ui}
40+
</CarouselContext.Provider>,
41+
);
42+
43+
describe('CarouselSlideDots', () => {
44+
const mockHandleUpdateSlideProps = jest.fn();
45+
46+
beforeEach(() => {
47+
mockHandleUpdateSlideProps.mockClear();
48+
});
49+
50+
it('renders the correct number of dots and highlights the active one', () => {
51+
renderWithContext(<CarouselSlideDots />, {
52+
numberOfSlides: 5,
53+
currentSlide: 2,
54+
handleUpdateSlideProps: mockHandleUpdateSlideProps,
55+
navigateWithDots: true,
56+
});
57+
58+
const dots = screen.getAllByRole('button');
59+
expect(dots).toHaveLength(5);
60+
61+
// The third dot (index 2) should be the selected one
62+
expect(dots[2]).toHaveClass('selected');
63+
expect(dots[1]).not.toHaveClass('selected');
64+
});
65+
66+
it('calls handler with positive index when navigating forward', () => {
67+
renderWithContext(<CarouselSlideDots />, {
68+
numberOfSlides: 5,
69+
currentSlide: 1,
70+
handleUpdateSlideProps: mockHandleUpdateSlideProps,
71+
navigateWithDots: true,
72+
});
73+
74+
fireEvent.click(screen.getByLabelText('Jump to slide 4'));
75+
expect(mockHandleUpdateSlideProps).toHaveBeenCalledWith(3);
76+
});
77+
78+
it('calls handler with negative index when navigating backward', () => {
79+
renderWithContext(<CarouselSlideDots />, {
80+
numberOfSlides: 5,
81+
currentSlide: 3,
82+
handleUpdateSlideProps: mockHandleUpdateSlideProps,
83+
navigateWithDots: true,
84+
});
85+
86+
fireEvent.click(screen.getByLabelText('Jump to slide 2'));
87+
expect(mockHandleUpdateSlideProps).toHaveBeenCalledWith(-1);
88+
});
89+
90+
it('does not call handler when clicking the active dot', () => {
91+
renderWithContext(<CarouselSlideDots />, {
92+
numberOfSlides: 5,
93+
currentSlide: 2,
94+
handleUpdateSlideProps: mockHandleUpdateSlideProps,
95+
navigateWithDots: true,
96+
});
97+
98+
fireEvent.click(screen.getByLabelText('Jump to slide 3'));
99+
expect(mockHandleUpdateSlideProps).not.toHaveBeenCalled();
100+
});
101+
102+
it('disables dots and sets aria-hidden when navigateWithDots is false', () => {
103+
renderWithContext(<CarouselSlideDots />, {
104+
numberOfSlides: 5,
105+
currentSlide: 0,
106+
handleUpdateSlideProps: mockHandleUpdateSlideProps,
107+
navigateWithDots: false,
108+
});
109+
110+
const container = screen.getByRole('navigation', { hidden: true });
111+
expect(container).toHaveAttribute('aria-hidden', 'true');
112+
113+
const dots = screen.getAllByRole('button', { hidden: true });
114+
dots.forEach((dot) => expect(dot).toBeDisabled());
115+
});
116+
});

src/core/carousel/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const initialBackwardCompatibilityProps: CarouselContextBackwardCompatibilityPro
1414
itemsShownOnDesktop: 3,
1515
itemsShownOnMobile: 1,
1616
withDots: false,
17+
navigateWithDots: true,
1718
onLoadMore: () => {},
1819
hasMore: false,
1920
loading: false,

src/core/carousel/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export type CarouselProps<T> = {
1919
* Boolean indicating whether the Carousel is shown with dots (number of sliders).
2020
*/
2121
withDots?: boolean;
22+
/**
23+
* Can the dots be used for navigation
24+
*/
25+
navigateWithDots?: boolean;
2226
/**
2327
* onLoadMore event handler.
2428
*/
@@ -44,6 +48,7 @@ export type CarouselProps<T> = {
4448
export type CarouselContextBackwardCompatibilityProps = Pick<
4549
CarouselProps<unknown>,
4650
| 'withDots'
51+
| 'navigateWithDots'
4752
| 'onLoadMore'
4853
| 'hasMore'
4954
| 'loading'

src/core/translation/constants.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const FALLBACK_TRANSLATION_KEYS = [
1212
'headerUniversalBarPrimaryLinkText',
1313
'carouselRegionLabelText',
1414
'carouselSliderRegionLabelText',
15+
'carouselSliderDotNavLabelText',
1516
] as const;
1617

1718
export const CITY_OF_HELSINKI_TRANSLATIONS: TranslationDictionary = {
@@ -39,9 +40,9 @@ export const LOCALIZED_HELSINKI_WEBSITE_URL: TranslationDictionary = {
3940
};
4041

4142
export const CAROUSEL_REGION_LABEL_TEXT: TranslationDictionary = {
42-
EN: 'A carousel {title}.',
43+
EN: 'Carousel {title}.',
4344
FI: 'Karusellikomponentti {title}.',
44-
SV: 'En karusell {title}.',
45+
SV: 'Karusell {title}.',
4546
};
4647

4748
export const CAROUSEL_SLIDER_REGION_LABEL_TEXT: TranslationDictionary = {
@@ -50,6 +51,12 @@ export const CAROUSEL_SLIDER_REGION_LABEL_TEXT: TranslationDictionary = {
5051
SV: 'I karusellsidan {currentSlide}. Listar 1 - {itemsPerSlide} objekt per sidan av {numberOfItems} objekt.',
5152
};
5253

54+
export const CAROUSEL_SLIDER_DOT_NAV_LABEL_TEXT: TranslationDictionary = {
55+
EN: 'Jump to slide {slideNumber}.',
56+
FI: 'Siirry sivulle {slideNumber}.',
57+
SV: 'Gå till sida {slideNumber}.',
58+
};
59+
5360
export const FALLBACK_TRANSLATIONS: Record<
5461
(typeof FALLBACK_TRANSLATION_KEYS)[number],
5562
TranslationDictionary
@@ -61,4 +68,5 @@ export const FALLBACK_TRANSLATIONS: Record<
6168
headerUniversalBarPrimaryLinkText: CITY_OF_HELSINKI_TRANSLATIONS,
6269
carouselRegionLabelText: CAROUSEL_REGION_LABEL_TEXT,
6370
carouselSliderRegionLabelText: CAROUSEL_SLIDER_REGION_LABEL_TEXT,
71+
carouselSliderDotNavLabelText: CAROUSEL_SLIDER_DOT_NAV_LABEL_TEXT,
6472
};

0 commit comments

Comments
 (0)