@lumx/react 3.9.3 → 3.9.4-alpha.1

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.
@@ -3,90 +3,102 @@ import { getFocusableElements } from '@lumx/react/utils/focus/getFocusableElemen
3
3
 
4
4
  export interface UseSlideFocusManagementProps {
5
5
  isSlideDisplayed?: boolean;
6
- slideRef: React.RefObject<HTMLDivElement>;
6
+ slidesRef?: React.RefObject<HTMLDivElement>;
7
7
  }
8
8
 
9
9
  /**
10
- * Classname set on elements whose focus was blocked.
10
+ * Data attribute set on elements whose focus was blocked.
11
11
  * This is to easily find elements that have been tempered with,
12
12
  * and not elements whose focus was already initially blocked.
13
- * */
14
- const BLOCKED_FOCUS_CLASSNAME = 'focus-blocked';
13
+ */
14
+ const BLOCKED_FOCUS = 'data-focus-blocked';
15
15
 
16
16
  /**
17
17
  * Manage how slides must behave when visible or not.
18
18
  * When not visible, they should be hidden from screen readers and not focusable.
19
19
  */
20
- export const useSlideFocusManagement = ({ isSlideDisplayed, slideRef }: UseSlideFocusManagementProps) => {
21
- useEffect(() => {
22
- const element = slideRef?.current;
20
+ export const useSlideFocusManagement = ({ isSlideDisplayed, slidesRef }: UseSlideFocusManagementProps) => {
21
+ const [slide, setSlide] = React.useState<HTMLDivElement | null>(null);
23
22
 
24
- if (!element) {
23
+ const [focusableElementSet, setFocusableElementSet] = React.useState<Set<HTMLElement>>();
24
+ React.useEffect(() => {
25
+ if (!slide) {
25
26
  return undefined;
26
27
  }
27
-
28
- /**
29
- * Display given slide to screen readers and, if focus was blocked, restore focus on elements.
30
- */
31
- const enableSlide = () => {
32
- // Hide from screen readers
33
- element.setAttribute('aria-hidden', 'false');
34
- // Find elements we have blocked focus on
35
- element.querySelectorAll(`.${BLOCKED_FOCUS_CLASSNAME}`).forEach((focusableElement) => {
36
- focusableElement.removeAttribute('tabindex');
37
- focusableElement.classList.remove(BLOCKED_FOCUS_CLASSNAME);
38
- });
39
- };
40
-
41
- /**
42
- * Hide given slide from screen readers and block focus on all focusable elements within.
43
- */
44
- const blockSlide = () => {
45
- element.setAttribute('aria-hidden', 'true');
46
- getFocusableElements(element).forEach((focusableElement) => {
47
- focusableElement.setAttribute('tabindex', '-1');
48
- focusableElement.classList.add(BLOCKED_FOCUS_CLASSNAME);
28
+ // Update the slide's focusable element list (including the blocked elements)
29
+ const updateFocusableElements = () =>
30
+ setFocusableElementSet((set = new Set()) => {
31
+ // TODO: remove when `inert` gets sufficient browser support
32
+ const focusedBlocked = Array.from(slide.querySelectorAll(`[${BLOCKED_FOCUS}]`)) as HTMLElement[];
33
+ for (const element of focusedBlocked) {
34
+ set.add(element);
35
+ }
36
+ for (const element of getFocusableElements(slide)) {
37
+ set.add(element);
38
+ }
39
+ return set;
49
40
  });
50
- };
51
41
 
52
- const handleDisplay = () => {
53
- if (!element) {
54
- return;
42
+ // Observe changes in the content of the slide
43
+ const observer = new MutationObserver((mutationsList) => {
44
+ if (mutationsList.some((mutation) => mutation.type === 'childList')) {
45
+ updateFocusableElements();
55
46
  }
56
- if (isSlideDisplayed) {
57
- enableSlide();
58
- } else {
59
- blockSlide();
47
+ });
48
+
49
+ updateFocusableElements();
50
+
51
+ observer.observe(slide, { attributes: true, childList: true, subtree: true });
52
+ return observer.disconnect();
53
+ }, [slide]);
54
+
55
+ useEffect(() => {
56
+ if (!slide || !focusableElementSet) {
57
+ return;
58
+ }
59
+ const focusableElements = Array.from(focusableElementSet);
60
+
61
+ if (!isSlideDisplayed) {
62
+ /* Block slide */
63
+ slide.setAttribute('inert', '');
64
+ slide.setAttribute('aria-hidden', 'true');
65
+
66
+ // TODO: remove when `inert` gets sufficient browser support
67
+ for (const focusableElement of focusableElements) {
68
+ focusableElement.setAttribute('tabindex', '-1');
69
+ focusableElement.setAttribute(BLOCKED_FOCUS, '');
60
70
  }
61
- };
62
-
63
- // Callback function to execute when mutations are observed
64
- const callback: MutationCallback = (mutationsList) => {
65
- if (element) {
66
- for (const mutation of mutationsList) {
67
- if (mutation.type === 'childList') {
68
- handleDisplay();
69
- }
70
- }
71
+ } else {
72
+ /* Un-block slide */
73
+ slide.removeAttribute('inert');
74
+ slide.removeAttribute('aria-hidden');
75
+
76
+ // TODO: remove when `inert` gets sufficient browser support
77
+ for (const focusableElement of focusableElements) {
78
+ focusableElement.removeAttribute('tabindex');
79
+ focusableElement.removeAttribute(BLOCKED_FOCUS);
71
80
  }
72
- };
73
81
 
74
- // Create an observer instance linked to the callback function
75
- const observer = new MutationObserver(callback);
82
+ // Change focus on slide displayed
83
+ const isUserActivated = slidesRef?.current?.dataset.lumxUserActivated === 'true';
84
+ if (isUserActivated) {
85
+ let elementToFocus: HTMLElement | undefined = slide;
76
86
 
77
- if (element) {
78
- handleDisplay();
87
+ // We have exactly one focusable element => focus it
88
+ if (focusableElementSet.size === 1) {
89
+ // eslint-disable-next-line prefer-destructuring
90
+ elementToFocus = focusableElements[0];
91
+ }
79
92
 
80
- /** If slide is hidden, start observing for elements to block focus */
81
- if (!isSlideDisplayed) {
82
- observer.observe(element, { attributes: true, childList: true, subtree: true });
93
+ // We have not focusable element => focus the pagination item
94
+ if (focusableElementSet.size === 0) {
95
+ elementToFocus = document.querySelector(`[aria-controls="${slide?.id}"]`) as HTMLElement;
96
+ }
97
+
98
+ elementToFocus?.focus({ preventScroll: true });
83
99
  }
84
100
  }
101
+ }, [focusableElementSet, isSlideDisplayed, slide, slidesRef]);
85
102
 
86
- return () => {
87
- if (!isSlideDisplayed) {
88
- observer.disconnect();
89
- }
90
- };
91
- }, [isSlideDisplayed, slideRef]);
103
+ return setSlide;
92
104
  };
@@ -1,9 +1,10 @@
1
- import { useState, useCallback, useEffect } from 'react';
2
-
1
+ import { SetStateAction, useCallback, useEffect, useState } from 'react';
2
+ import { clamp } from '@lumx/react';
3
3
  import { useInterval } from '@lumx/react/hooks/useInterval';
4
- import { AUTOPLAY_DEFAULT_INTERVAL } from '@lumx/react/components/slideshow/constants';
5
4
  import { useId } from '@lumx/react/hooks/useId';
6
5
 
6
+ import { AUTOPLAY_DEFAULT_INTERVAL } from './constants';
7
+
7
8
  export interface UseSlideshowControlsOptions {
8
9
  /** default active index to be displayed */
9
10
  defaultActiveIndex?: number;
@@ -43,9 +44,9 @@ export interface UseSlideshowControls {
43
44
  /** id to be used for the wrapper that contains the slides */
44
45
  slideshowSlidesId: string;
45
46
  /** callback that triggers the previous slide while using the slideshow controls */
46
- onPreviousClick: (loopback: boolean) => void;
47
+ onPreviousClick: (loopBack: boolean) => void;
47
48
  /** callback that triggers the next slide while using the slideshow controls */
48
- onNextClick: (loopback: boolean) => void;
49
+ onNextClick: (loopBack: boolean) => void;
49
50
  /** callback that triggers a specific page while using the slideshow controls */
50
51
  onPaginationClick: (index: number) => void;
51
52
  /** whether the slideshow is autoplaying or not */
@@ -54,16 +55,18 @@ export interface UseSlideshowControls {
54
55
  isForcePaused: boolean;
55
56
  /** callback to change whether the slideshow is autoplaying or not */
56
57
  toggleAutoPlay: () => void;
57
- /** calback to change whether the slideshow should be force paused or not */
58
+ /** callback to change whether the slideshow should be force paused or not */
58
59
  toggleForcePause: () => void;
59
60
  /** current active slide index */
60
61
  activeIndex: number;
61
62
  /** set the current index as the active one */
62
63
  setActiveIndex: (index: number) => void;
63
- /** callback that stops the auto play */
64
+ /** callback that stops the autoplay */
64
65
  stopAutoPlay: () => void;
65
- /** callback that starts the auto play */
66
+ /** callback that starts the autoplay */
66
67
  startAutoPlay: () => void;
68
+ /** True if the last slide change is user activated */
69
+ isUserActivated?: boolean;
67
70
  }
68
71
 
69
72
  export const DEFAULT_OPTIONS: Partial<UseSlideshowControlsOptions> = {
@@ -90,40 +93,34 @@ export const useSlideshowControls = ({
90
93
  // Number of slides when using groupBy prop.
91
94
  const slidesCount = Math.ceil(itemsCount / Math.min(groupBy as number, itemsCount));
92
95
 
93
- // Change current index to display next slide.
94
- const goToNextSlide = useCallback(
95
- (loopback = true) => {
96
- setCurrentIndex((index) => {
97
- if (loopback && index === slidesCount - 1) {
98
- // Loopback to the start.
99
- return 0;
100
- }
101
- if (index < slidesCount - 1) {
102
- // Next slide.
103
- return index + 1;
104
- }
105
- return index;
106
- });
96
+ // Set current active index (& if is user activated)
97
+ const setActiveIndex = useCallback(
98
+ (setStateAction: SetStateAction<number>, isUser?: boolean) => {
99
+ // Store on element a boolean value when the slide change was not from a user action.
100
+ const elementDataset = element?.dataset as any;
101
+ if (elementDataset) {
102
+ if (isUser) elementDataset.lumxUserActivated = true;
103
+ else delete elementDataset.lumxUserActivated;
104
+ }
105
+
106
+ setCurrentIndex(setStateAction);
107
107
  },
108
- [slidesCount, setCurrentIndex],
108
+ [element],
109
109
  );
110
110
 
111
- // Change current index to display previous slide.
112
- const goToPreviousSlide = useCallback(
113
- (loopback = true) => {
114
- setCurrentIndex((index) => {
115
- if (loopback && index === 0) {
116
- // Loopback to the end.
117
- return slidesCount - 1;
111
+ // Change slide given delta (-1/+1) with or without loop back.
112
+ const goTo = useCallback(
113
+ (delta: -1 | 1 = 1, loopBack = true, isUser?: boolean) => {
114
+ setActiveIndex((index) => {
115
+ if (loopBack) {
116
+ const newIndex = (index + delta) % slidesCount;
117
+ if (newIndex < 0) return slidesCount + newIndex;
118
+ return newIndex;
118
119
  }
119
- if (index > 0) {
120
- // Previous slide.
121
- return index - 1;
122
- }
123
- return index;
124
- });
120
+ return clamp(index + delta, 0, slidesCount - 1);
121
+ }, isUser);
125
122
  },
126
- [slidesCount, setCurrentIndex],
123
+ [slidesCount, setActiveIndex],
127
124
  );
128
125
 
129
126
  // Auto play
@@ -132,22 +129,22 @@ export const useSlideshowControls = ({
132
129
 
133
130
  const isSlideshowAutoPlaying = isForcePaused ? false : isAutoPlaying;
134
131
  // Start
135
- useInterval(goToNextSlide, isSlideshowAutoPlaying && slidesCount > 1 ? (interval as number) : null);
132
+ useInterval(goTo, isSlideshowAutoPlaying && slidesCount > 1 ? (interval as number) : null);
136
133
 
137
- // Reset current index if it become invalid.
134
+ // Reset current index if it becomes invalid.
138
135
  useEffect(() => {
139
136
  if (currentIndex > slidesCount - 1) {
140
- setCurrentIndex(defaultActiveIndex as number);
137
+ setActiveIndex(defaultActiveIndex as number);
141
138
  }
142
- }, [currentIndex, slidesCount, defaultActiveIndex]);
139
+ }, [currentIndex, slidesCount, defaultActiveIndex, setActiveIndex]);
143
140
 
144
- const startAutoPlay = () => {
141
+ const startAutoPlay = useCallback(() => {
145
142
  setIsAutoPlaying(Boolean(autoPlay));
146
- };
143
+ }, [autoPlay]);
147
144
 
148
- const stopAutoPlay = () => {
145
+ const stopAutoPlay = useCallback(() => {
149
146
  setIsAutoPlaying(false);
150
- };
147
+ }, []);
151
148
 
152
149
  // Handle click on a bullet to go to a specific slide.
153
150
  const onPaginationClick = useCallback(
@@ -156,36 +153,36 @@ export const useSlideshowControls = ({
156
153
  setIsForcePaused(true);
157
154
 
158
155
  if (index >= 0 && index < slidesCount) {
159
- setCurrentIndex(index);
156
+ setActiveIndex(index, true);
160
157
  }
161
158
  },
162
- [slidesCount, setCurrentIndex],
159
+ [stopAutoPlay, slidesCount, setActiveIndex],
163
160
  );
164
161
 
165
162
  // Handle click or keyboard event to go to next slide.
166
163
  const onNextClick = useCallback(
167
- (loopback = true) => {
164
+ (loopBack = true) => {
168
165
  stopAutoPlay();
169
166
  setIsForcePaused(true);
170
- goToNextSlide(loopback);
167
+ goTo(1, loopBack, true);
171
168
  },
172
- [goToNextSlide],
169
+ [goTo, stopAutoPlay],
173
170
  );
174
171
 
175
172
  // Handle click or keyboard event to go to previous slide.
176
173
  const onPreviousClick = useCallback(
177
- (loopback = true) => {
174
+ (loopBack = true) => {
178
175
  stopAutoPlay();
179
176
  setIsForcePaused(true);
180
- goToPreviousSlide(loopback);
177
+ goTo(-1, loopBack, true);
181
178
  },
182
- [goToPreviousSlide],
179
+ [goTo, stopAutoPlay],
183
180
  );
184
181
 
185
182
  // If the activeIndex props changes, update the current slide
186
183
  useEffect(() => {
187
- setCurrentIndex(activeIndex as number);
188
- }, [activeIndex]);
184
+ setActiveIndex(activeIndex as number);
185
+ }, [activeIndex, setActiveIndex]);
189
186
 
190
187
  // If the slide changes, with autoplay for example, trigger "onChange"
191
188
  useEffect(() => {
@@ -199,15 +196,15 @@ export const useSlideshowControls = ({
199
196
  const generatedSlidesId = useId();
200
197
  const slideshowSlidesId = slidesId || generatedSlidesId;
201
198
 
202
- const toggleAutoPlay = () => {
199
+ const toggleAutoPlay = useCallback(() => {
203
200
  if (isSlideshowAutoPlaying) {
204
201
  stopAutoPlay();
205
202
  } else {
206
203
  startAutoPlay();
207
204
  }
208
- };
205
+ }, [isSlideshowAutoPlaying, startAutoPlay, stopAutoPlay]);
209
206
 
210
- const toggleForcePause = () => {
207
+ const toggleForcePause = useCallback(() => {
211
208
  const shouldBePaused = !isForcePaused;
212
209
 
213
210
  setIsForcePaused(shouldBePaused);
@@ -217,7 +214,7 @@ export const useSlideshowControls = ({
217
214
  } else {
218
215
  stopAutoPlay();
219
216
  }
220
- };
217
+ }, [isForcePaused, startAutoPlay, stopAutoPlay]);
221
218
 
222
219
  // Start index and end index of visible slides.
223
220
  const startIndexVisible = currentIndex * (groupBy as number);
@@ -237,7 +234,7 @@ export const useSlideshowControls = ({
237
234
  toggleAutoPlay,
238
235
  activeIndex: currentIndex,
239
236
  slidesCount,
240
- setActiveIndex: setCurrentIndex,
237
+ setActiveIndex,
241
238
  startAutoPlay,
242
239
  stopAutoPlay,
243
240
  isForcePaused,