@lumx/react 3.9.4-alpha.3 → 3.9.4

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.
@@ -1,18 +1,16 @@
1
- import React, { forwardRef, RefObject, useCallback, useState } from 'react';
1
+ import React, { forwardRef, RefObject, useCallback, useMemo } from 'react';
2
2
 
3
3
  import classNames from 'classnames';
4
4
  import range from 'lodash/range';
5
5
 
6
6
  import { mdiChevronLeft, mdiChevronRight, mdiPlayCircleOutline, mdiPauseCircleOutline } from '@lumx/icons';
7
- import { Emphasis, IconButton, IconButtonProps, Slides, Theme } from '@lumx/react';
7
+ import { Emphasis, IconButton, IconButtonProps, Theme } from '@lumx/react';
8
8
  import { Comp, GenericProps, HasTheme } from '@lumx/react/utils/type';
9
9
  import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
10
10
  import { WINDOW } from '@lumx/react/constants';
11
- import { useKeyNavigate } from '@lumx/react/components/slideshow/useKeyNavigate';
12
- import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
11
+ import { useSlideshowControls, DEFAULT_OPTIONS } from '@lumx/react/hooks/useSlideshowControls';
12
+ import { useRovingTabIndex } from '@lumx/react/hooks/useRovingTabIndex';
13
13
 
14
- import { buildSlideShowGroupId } from '@lumx/react/components/slideshow/SlideshowItemGroup';
15
- import { DEFAULT_OPTIONS, useSlideshowControls } from './useSlideshowControls';
16
14
  import { useSwipeNavigate } from './useSwipeNavigate';
17
15
  import { PAGINATION_ITEM_SIZE, PAGINATION_ITEMS_MAX } from './constants';
18
16
  import { usePaginationVisibleRange } from './usePaginationVisibleRange';
@@ -36,11 +34,11 @@ export interface SlideshowControlsProps extends GenericProps, HasTheme {
36
34
  /** Number of slides. */
37
35
  slidesCount: number;
38
36
  /** On next button click callback. */
39
- onNextClick?(loopBack?: boolean): void;
37
+ onNextClick?(loopback?: boolean): void;
40
38
  /** On pagination change callback. */
41
39
  onPaginationClick?(index: number): void;
42
40
  /** On previous button click callback. */
43
- onPreviousClick?(loopBack?: boolean): void;
41
+ onPreviousClick?(loopback?: boolean): void;
44
42
  /** whether the slideshow is currently playing */
45
43
  isAutoPlaying?: boolean;
46
44
  /**
@@ -102,7 +100,7 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
102
100
  ...forwardedProps
103
101
  } = props;
104
102
 
105
- let parent: HTMLElement | null | undefined;
103
+ let parent;
106
104
  if (WINDOW) {
107
105
  // Checking window object to avoid errors in SSR.
108
106
  parent = parentRef instanceof HTMLElement ? parentRef : parentRef?.current;
@@ -111,30 +109,33 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
111
109
  // Listen to touch swipe navigate left & right.
112
110
  useSwipeNavigate(
113
111
  parent,
114
- // Go next without loop back.
112
+ // Go next without loopback.
115
113
  useCallback(() => onNextClick?.(false), [onNextClick]),
116
- // Go previous without loop back.
114
+ // Go previous without loopback.
117
115
  useCallback(() => onPreviousClick?.(false), [onPreviousClick]),
118
116
  );
119
117
 
120
- const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
121
- const onButtonFocus = useCallback((index: number) => () => setFocusedIndex(index), [setFocusedIndex]);
122
- const onFocusOut = useCallback(() => setFocusedIndex(null), [setFocusedIndex]);
118
+ /**
119
+ * Add roving tab index pattern to pagination items and activate slide on focus.
120
+ */
121
+ useRovingTabIndex({
122
+ parentRef: paginationRef,
123
+ elementSelector: 'button',
124
+ keepTabIndex: true,
125
+ onElementFocus: (element) => {
126
+ element.click();
127
+ },
128
+ });
123
129
 
124
130
  // Pagination "bullet" range.
125
- const visibleRange = usePaginationVisibleRange(focusedIndex ?? (activeIndex as number), slidesCount);
131
+ const visibleRange = usePaginationVisibleRange(activeIndex as number, slidesCount);
126
132
 
127
133
  // Inline style of wrapper element.
128
134
  const wrapperStyle = { transform: `translateX(-${PAGINATION_ITEM_SIZE * visibleRange.min}px)` };
129
135
 
130
- const controlsRef = React.useRef<HTMLDivElement>(null);
131
- useKeyNavigate(controlsRef.current, onNextClick, onPreviousClick);
132
-
133
- const slideshowSlidesId = React.useMemo(() => parent?.querySelector(`.${Slides.className}__slides`)?.id, [parent]);
134
-
135
136
  return (
136
137
  <div
137
- ref={useMergeRefs(ref, controlsRef)}
138
+ ref={ref}
138
139
  {...forwardedProps}
139
140
  className={classNames(className, handleBasicClasses({ prefix: CLASSNAME, theme }), {
140
141
  [`${CLASSNAME}--has-infinite-pagination`]: slidesCount > PAGINATION_ITEMS_MAX,
@@ -147,53 +148,64 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
147
148
  color={theme === Theme.dark ? 'light' : 'dark'}
148
149
  emphasis={Emphasis.low}
149
150
  onClick={onPreviousClick}
150
- aria-controls={slideshowSlidesId}
151
151
  />
152
-
153
152
  <div ref={paginationRef} className={`${CLASSNAME}__pagination`}>
154
153
  <div
155
154
  className={`${CLASSNAME}__pagination-items`}
156
155
  style={wrapperStyle}
156
+ role="tablist"
157
157
  {...paginationProps}
158
- onBlur={onFocusOut}
159
158
  >
160
- {range(slidesCount).map((index) => {
161
- const isOnEdge =
162
- index !== 0 &&
163
- index !== slidesCount - 1 &&
164
- (index === visibleRange.min || index === visibleRange.max);
165
- const isActive = activeIndex === index;
166
- const isOutRange = index < visibleRange.min || index > visibleRange.max;
167
- const {
168
- className: itemClassName = undefined,
169
- label = undefined,
170
- ...itemProps
171
- } = paginationItemProps ? paginationItemProps(index) : {};
172
-
173
- const ariaLabel = label || paginationItemLabel?.(index) || `${index + 1} / ${slidesCount}`;
174
-
175
- return (
176
- <button
177
- className={classNames(
178
- handleBasicClasses({
179
- prefix: `${CLASSNAME}__pagination-item`,
180
- isActive,
181
- isOnEdge,
182
- isOutRange,
183
- }),
184
- itemClassName,
185
- )}
186
- key={index}
187
- type="button"
188
- aria-current={isActive || undefined}
189
- aria-controls={buildSlideShowGroupId(slideshowSlidesId, index)}
190
- onClick={() => onPaginationClick?.(index)}
191
- onFocus={onButtonFocus(index)}
192
- aria-label={ariaLabel}
193
- {...itemProps}
194
- />
195
- );
196
- })}
159
+ {useMemo(
160
+ () =>
161
+ range(slidesCount).map((index) => {
162
+ const isOnEdge =
163
+ index !== 0 &&
164
+ index !== slidesCount - 1 &&
165
+ (index === visibleRange.min || index === visibleRange.max);
166
+ const isActive = activeIndex === index;
167
+ const isOutRange = index < visibleRange.min || index > visibleRange.max;
168
+ const {
169
+ className: itemClassName = undefined,
170
+ label = undefined,
171
+ ...itemProps
172
+ } = paginationItemProps ? paginationItemProps(index) : {};
173
+
174
+ const ariaLabel =
175
+ label || paginationItemLabel?.(index) || `${index + 1} / ${slidesCount}`;
176
+
177
+ return (
178
+ <button
179
+ className={classNames(
180
+ handleBasicClasses({
181
+ prefix: `${CLASSNAME}__pagination-item`,
182
+ isActive,
183
+ isOnEdge,
184
+ isOutRange,
185
+ }),
186
+ itemClassName,
187
+ )}
188
+ key={index}
189
+ type="button"
190
+ tabIndex={isActive ? undefined : -1}
191
+ role="tab"
192
+ aria-selected={isActive}
193
+ onClick={() => onPaginationClick?.(index)}
194
+ aria-label={ariaLabel}
195
+ {...itemProps}
196
+ />
197
+ );
198
+ }),
199
+ [
200
+ slidesCount,
201
+ visibleRange.min,
202
+ visibleRange.max,
203
+ activeIndex,
204
+ paginationItemProps,
205
+ paginationItemLabel,
206
+ onPaginationClick,
207
+ ],
208
+ )}
197
209
  </div>
198
210
  </div>
199
211
 
@@ -204,7 +216,6 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
204
216
  className={`${CLASSNAME}__play`}
205
217
  color={theme === Theme.dark ? 'light' : 'dark'}
206
218
  emphasis={Emphasis.low}
207
- aria-controls={slideshowSlidesId}
208
219
  />
209
220
  ) : null}
210
221
 
@@ -215,7 +226,6 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
215
226
  color={theme === Theme.dark ? 'light' : 'dark'}
216
227
  emphasis={Emphasis.low}
217
228
  onClick={onNextClick}
218
- aria-controls={slideshowSlidesId}
219
229
  />
220
230
  </div>
221
231
  );
@@ -33,7 +33,16 @@ const CLASSNAME = getRootClassName(COMPONENT_NAME);
33
33
  export const SlideshowItem: Comp<SlideshowItemProps, HTMLDivElement> = forwardRef((props, ref) => {
34
34
  const { className, children, ...forwardedProps } = props;
35
35
  return (
36
- <div ref={ref} className={classNames(className, CLASSNAME)} {...forwardedProps}>
36
+ <div
37
+ ref={ref}
38
+ className={classNames(
39
+ className,
40
+ handleBasicClasses({
41
+ prefix: CLASSNAME,
42
+ }),
43
+ )}
44
+ {...forwardedProps}
45
+ >
37
46
  {children}
38
47
  </div>
39
48
  );
@@ -1,17 +1,19 @@
1
- import React from 'react';
1
+ import React, { forwardRef } from 'react';
2
2
 
3
- import { getRootClassName } from '@lumx/react/utils/className';
3
+ import classNames from 'classnames';
4
+ import { mergeRefs } from '@lumx/react/utils/mergeRefs';
4
5
 
6
+ import { Comp, GenericProps } from '@lumx/react/utils/type';
7
+ import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
5
8
  import { useSlideFocusManagement } from './useSlideFocusManagement';
6
9
 
7
10
  /**
8
11
  * Defines the props of the component.
9
12
  */
10
- export interface SlideshowItemGroupProps {
11
- id?: string;
13
+ export interface SlideshowItemGroupProps extends GenericProps {
14
+ role?: 'tabpanel' | 'group';
12
15
  label?: string;
13
16
  isDisplayed?: boolean;
14
- slidesRef?: React.RefObject<HTMLDivElement>;
15
17
  }
16
18
 
17
19
  /**
@@ -24,24 +26,39 @@ const COMPONENT_NAME = 'SlideshowItemGroup';
24
26
  */
25
27
  export const CLASSNAME = getRootClassName(COMPONENT_NAME);
26
28
 
27
- export const buildSlideShowGroupId = (slidesId: string | undefined, index: number) =>
28
- slidesId && `${slidesId}-slide-${index}`;
29
+ export const buildSlideShowGroupId = (slidesId: string, index: number) => `${slidesId}-slide-${index}`;
29
30
 
30
31
  /**
31
- * Internal slideshow item group component.
32
+ * SlideshowItemGroup component.
33
+ *
34
+ * @param props Component props.
35
+ * @param ref Component ref.
36
+ * @return React element.
32
37
  */
33
- export const SlideshowItemGroup: React.FC<SlideshowItemGroupProps> = (props) => {
34
- const { id, children, label, isDisplayed, slidesRef } = props;
38
+ export const SlideshowItemGroup: Comp<SlideshowItemGroupProps, HTMLDivElement> = forwardRef((props, ref) => {
39
+ const { className, children, role = 'group', label, isDisplayed, ...forwardedProps } = props;
40
+ const groupRef = React.useRef<HTMLDivElement>(null);
35
41
 
36
- const groupRef = useSlideFocusManagement({
37
- isSlideDisplayed: isDisplayed,
38
- slidesRef,
39
- });
42
+ useSlideFocusManagement({ isSlideDisplayed: isDisplayed, slideRef: groupRef });
40
43
 
41
44
  return (
42
- <div id={id} ref={groupRef} role="group" className={CLASSNAME} aria-label={label} tabIndex={-1}>
45
+ <div
46
+ ref={mergeRefs(groupRef, ref)}
47
+ role={role}
48
+ className={classNames(
49
+ className,
50
+ handleBasicClasses({
51
+ prefix: CLASSNAME,
52
+ }),
53
+ )}
54
+ aria-roledescription="slide"
55
+ aria-label={label}
56
+ {...forwardedProps}
57
+ >
43
58
  {children}
44
59
  </div>
45
60
  );
46
- };
61
+ });
62
+
47
63
  SlideshowItemGroup.displayName = COMPONENT_NAME;
64
+ SlideshowItemGroup.className = CLASSNAME;
@@ -22,7 +22,3 @@ export const PAGINATION_ITEMS_MAX = 5;
22
22
  * Size of a pagination item. Used to translate wrapper.
23
23
  */
24
24
  export const PAGINATION_ITEM_SIZE = 12;
25
-
26
-
27
- export const NEXT_SLIDE_EVENT = 'lumx-next-slide-event';
28
- export const PREV_SLIDE_EVENT = 'lumx-prev-slide-event';
@@ -3,102 +3,90 @@ import { getFocusableElements } from '@lumx/react/utils/focus/getFocusableElemen
3
3
 
4
4
  export interface UseSlideFocusManagementProps {
5
5
  isSlideDisplayed?: boolean;
6
- slidesRef?: React.RefObject<HTMLDivElement>;
6
+ slideRef: React.RefObject<HTMLDivElement>;
7
7
  }
8
8
 
9
9
  /**
10
- * Data attribute set on elements whose focus was blocked.
10
+ * Classname 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 = 'data-focus-blocked';
13
+ * */
14
+ const BLOCKED_FOCUS_CLASSNAME = '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, slidesRef }: UseSlideFocusManagementProps) => {
21
- const [slide, setSlide] = React.useState<HTMLDivElement | null>(null);
20
+ export const useSlideFocusManagement = ({ isSlideDisplayed, slideRef }: UseSlideFocusManagementProps) => {
21
+ useEffect(() => {
22
+ const element = slideRef?.current;
22
23
 
23
- const [focusableElementSet, setFocusableElementSet] = React.useState<Set<HTMLElement>>();
24
- React.useEffect(() => {
25
- if (!slide) {
24
+ if (!element) {
26
25
  return undefined;
27
26
  }
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;
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);
40
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);
49
+ });
50
+ };
41
51
 
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();
52
+ const handleDisplay = () => {
53
+ if (!element) {
54
+ return;
46
55
  }
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, '');
56
+ if (isSlideDisplayed) {
57
+ enableSlide();
58
+ } else {
59
+ blockSlide();
70
60
  }
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);
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
+ }
80
71
  }
72
+ };
81
73
 
82
- // Change focus on slide displayed
83
- const isUserActivated = slidesRef?.current?.dataset.lumxUserActivated === 'true';
84
- if (isUserActivated) {
85
- let elementToFocus: HTMLElement | undefined = slide;
74
+ // Create an observer instance linked to the callback function
75
+ const observer = new MutationObserver(callback);
86
76
 
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
- }
77
+ if (element) {
78
+ handleDisplay();
92
79
 
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 });
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 });
99
83
  }
100
84
  }
101
- }, [focusableElementSet, isSlideDisplayed, slide, slidesRef]);
102
85
 
103
- return setSlide;
86
+ return () => {
87
+ if (!isSlideDisplayed) {
88
+ observer.disconnect();
89
+ }
90
+ };
91
+ }, [isSlideDisplayed, slideRef]);
104
92
  };