@lumx/react 3.9.2 → 3.9.4-alpha.0

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,7 +3,7 @@ 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
  /**
@@ -17,42 +17,45 @@ const BLOCKED_FOCUS_CLASSNAME = 'focus-blocked';
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) => {
20
+ export const useSlideFocusManagement = ({ isSlideDisplayed, slidesRef }: UseSlideFocusManagementProps) => {
21
+ const [slide, setSlide] = React.useState<HTMLDivElement | null>(null);
21
22
  useEffect(() => {
22
- const element = slideRef?.current;
23
-
24
- if (!element) {
23
+ if (!slide) {
25
24
  return undefined;
26
25
  }
27
26
 
27
+ const isUserActivated = slidesRef?.current?.dataset.lumxUserActivated === 'true';
28
+ let focusableElements: Array<HTMLElement> = [];
29
+
28
30
  /**
29
31
  * Display given slide to screen readers and, if focus was blocked, restore focus on elements.
30
32
  */
31
33
  const enableSlide = () => {
32
- // Hide from screen readers
33
- element.setAttribute('aria-hidden', 'false');
34
+ slide.removeAttribute('inert');
35
+ slide.setAttribute('aria-hidden', 'false');
34
36
  // Find elements we have blocked focus on
35
- element.querySelectorAll(`.${BLOCKED_FOCUS_CLASSNAME}`).forEach((focusableElement) => {
37
+ // (won't be necessary once "inert" gets sufficient browser support)
38
+ focusableElements = Array.from(slide.querySelectorAll(`.${BLOCKED_FOCUS_CLASSNAME}`));
39
+ for (const focusableElement of focusableElements) {
36
40
  focusableElement.removeAttribute('tabindex');
37
41
  focusableElement.classList.remove(BLOCKED_FOCUS_CLASSNAME);
38
- });
42
+ }
39
43
  };
40
44
 
41
45
  /**
42
46
  * Hide given slide from screen readers and block focus on all focusable elements within.
43
47
  */
44
48
  const blockSlide = () => {
45
- element.setAttribute('aria-hidden', 'true');
46
- getFocusableElements(element).forEach((focusableElement) => {
49
+ slide.setAttribute('inert', '');
50
+ slide.setAttribute('aria-hidden', 'true');
51
+ focusableElements = getFocusableElements(slide);
52
+ for (const focusableElement of focusableElements) {
47
53
  focusableElement.setAttribute('tabindex', '-1');
48
54
  focusableElement.classList.add(BLOCKED_FOCUS_CLASSNAME);
49
- });
55
+ }
50
56
  };
51
57
 
52
58
  const handleDisplay = () => {
53
- if (!element) {
54
- return;
55
- }
56
59
  if (isSlideDisplayed) {
57
60
  enableSlide();
58
61
  } else {
@@ -60,33 +63,45 @@ export const useSlideFocusManagement = ({ isSlideDisplayed, slideRef }: UseSlide
60
63
  }
61
64
  };
62
65
 
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
- }
66
+ // Create an observer instance linked to the callback function
67
+ // (won't be necessary once "inert" gets sufficient browser support)
68
+ const observer = new MutationObserver((mutationsList) => {
69
+ for (const mutation of mutationsList) {
70
+ if (mutation.type === 'childList') {
71
+ handleDisplay();
70
72
  }
71
73
  }
72
- };
74
+ });
73
75
 
74
- // Create an observer instance linked to the callback function
75
- const observer = new MutationObserver(callback);
76
+ handleDisplay();
76
77
 
77
- if (element) {
78
- handleDisplay();
78
+ // Change focus on slide displayed
79
+ if (isSlideDisplayed && isUserActivated) {
80
+ let elementToFocus: HTMLElement | undefined = slide;
79
81
 
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 });
82
+ // We have exactly one focusable element => focus it
83
+ if (focusableElements.length === 1) {
84
+ // eslint-disable-next-line prefer-destructuring
85
+ elementToFocus = focusableElements[0];
83
86
  }
84
- }
85
87
 
86
- return () => {
87
- if (!isSlideDisplayed) {
88
- observer.disconnect();
88
+ // We have not focusable element => focus the pagination item
89
+ if (focusableElements.length === 0) {
90
+ elementToFocus = document.querySelector(`[aria-controls="${slide?.id}"]`) as HTMLElement;
89
91
  }
90
- };
91
- }, [isSlideDisplayed, slideRef]);
92
+
93
+ elementToFocus?.focus({ preventScroll: true });
94
+ }
95
+
96
+ /** If slide is hidden, start observing for elements to block focus */
97
+ if (!isSlideDisplayed) {
98
+ observer.observe(slide, { attributes: true, childList: true, subtree: true });
99
+
100
+ return () => observer.disconnect();
101
+ }
102
+
103
+ return undefined;
104
+ }, [isSlideDisplayed, slide, slidesRef]);
105
+
106
+ return setSlide;
92
107
  };
@@ -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,
@@ -5,8 +5,9 @@ import { screen, render } from '@testing-library/react';
5
5
  import { queryAllByTagName, queryByClassName } from '@lumx/react/testing/utils/queries';
6
6
  import { commonTestsSuiteRTL } from '@lumx/react/testing/utils';
7
7
  import userEvent from '@testing-library/user-event';
8
-
9
8
  import { isFocusVisible } from '@lumx/react/utils/isFocusVisible';
9
+ import { VISUALLY_HIDDEN } from '@lumx/react/constants';
10
+
10
11
  import { Tooltip, TooltipProps } from './Tooltip';
11
12
 
12
13
  const CLASSNAME = Tooltip.className as string;
@@ -142,11 +143,11 @@ describe(`<${Tooltip.displayName}>`, () => {
142
143
  forceOpen: false,
143
144
  });
144
145
  expect(tooltip).toBeInTheDocument();
145
- expect(tooltip).toHaveClass('lumx-tooltip--is-hidden');
146
+ expect(tooltip).toHaveClass(VISUALLY_HIDDEN);
146
147
 
147
148
  const anchor = screen.getByRole('button', { name: 'Anchor' });
148
149
  await userEvent.hover(anchor);
149
- expect(tooltip).not.toHaveClass('lumx-tooltip--is-hidden');
150
+ expect(tooltip).not.toHaveClass(VISUALLY_HIDDEN);
150
151
  });
151
152
  });
152
153
 
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
4
4
 
5
5
  import classNames from 'classnames';
6
6
 
7
- import { DOCUMENT } from '@lumx/react/constants';
7
+ import { DOCUMENT, VISUALLY_HIDDEN } from '@lumx/react/constants';
8
8
  import { Comp, GenericProps, HasCloseMode } from '@lumx/react/utils/type';
9
9
  import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
10
10
  import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
@@ -106,6 +106,7 @@ export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, re
106
106
  const { isOpen: isActivated, onPopperMount } = useTooltipOpen(delay, anchorElement);
107
107
  const isOpen = (isActivated || forceOpen) && !!label;
108
108
  const isMounted = !!label && (isOpen || closeMode === 'hide');
109
+ const isHidden = !isOpen && closeMode === 'hide';
109
110
  const wrappedChildren = useInjectTooltipRef({
110
111
  children,
111
112
  setAnchorElement,
@@ -139,8 +140,8 @@ export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, re
139
140
  handleBasicClasses({
140
141
  prefix: CLASSNAME,
141
142
  position,
142
- hidden: !isOpen && closeMode === 'hide',
143
143
  }),
144
+ isHidden && VISUALLY_HIDDEN,
144
145
  )}
145
146
  style={{ ...styles.popper, zIndex }}
146
147
  {...attributes.popper}
package/src/constants.ts CHANGED
@@ -20,3 +20,8 @@ export const DOCUMENT = typeof document !== 'undefined' ? document : undefined;
20
20
  * Check if we are running in a true browser
21
21
  */
22
22
  export const IS_BROWSER = typeof navigator !== 'undefined' && !navigator.userAgent.includes('jsdom');
23
+
24
+ /**
25
+ * Visually hidden a11y utility class name
26
+ */
27
+ export const VISUALLY_HIDDEN = 'visually-hidden';
@@ -5,9 +5,21 @@ import { MaybeElementOrRef } from '@lumx/react/utils/type';
5
5
  import { unref } from '../react/unref';
6
6
  import { getPrefersReducedMotion } from '../browser/getPrefersReducedMotion';
7
7
 
8
- function setTransitionViewName(elementRef: MaybeElementOrRef<HTMLElement>, name: string | null | undefined) {
9
- const element = unref(elementRef) as any;
10
- if (element) element.style.viewTransitionName = name;
8
+ function setupViewTransitionName(elementRef: MaybeElementOrRef<HTMLElement>, name: string) {
9
+ let originalName: string | null = null;
10
+ return {
11
+ set() {
12
+ const element = unref(elementRef);
13
+ if (!element) return;
14
+ originalName = element.style.viewTransitionName;
15
+ element.style.viewTransitionName = name;
16
+ },
17
+ unset() {
18
+ const element = unref(elementRef);
19
+ if (!element || originalName === null) return;
20
+ element.style.viewTransitionName = originalName;
21
+ },
22
+ };
11
23
  }
12
24
 
13
25
  /**
@@ -37,20 +49,20 @@ export async function startViewTransition({
37
49
  return;
38
50
  }
39
51
 
40
- // Set transition name on source element
41
- setTransitionViewName(viewTransitionName.source, viewTransitionName.name);
52
+ // Setup set/unset transition name on source & target
53
+ const sourceTransitionName = setupViewTransitionName(viewTransitionName.source, viewTransitionName.name);
54
+ const targetTransitionName = setupViewTransitionName(viewTransitionName.target, viewTransitionName.name);
55
+
56
+ sourceTransitionName.set();
42
57
 
43
58
  // Start view transition, apply changes & flush to DOM
44
59
  await start(() => {
45
- // Un-set transition name on source element
46
- setTransitionViewName(viewTransitionName.source, null);
60
+ sourceTransitionName.unset();
47
61
 
48
62
  flushSync(changes);
49
63
 
50
- // Set transition name on target element
51
- setTransitionViewName(viewTransitionName.target, viewTransitionName.name);
64
+ targetTransitionName.set();
52
65
  }).updateCallbackDone;
53
66
 
54
- // Un-set transition name on target element
55
- setTransitionViewName(viewTransitionName.target, null);
67
+ targetTransitionName.unset();
56
68
  }