@lumx/react 3.6.3 → 3.6.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.
package/package.json CHANGED
@@ -7,8 +7,8 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@juggle/resize-observer": "^3.2.0",
10
- "@lumx/core": "^3.6.3",
11
- "@lumx/icons": "^3.6.3",
10
+ "@lumx/core": "^3.6.4",
11
+ "@lumx/icons": "^3.6.4",
12
12
  "@popperjs/core": "^2.5.4",
13
13
  "body-scroll-lock": "^3.1.5",
14
14
  "classnames": "^2.3.2",
@@ -112,5 +112,5 @@
112
112
  "build:storybook": "storybook build"
113
113
  },
114
114
  "sideEffects": false,
115
- "version": "3.6.3"
115
+ "version": "3.6.4"
116
116
  }
@@ -4,11 +4,14 @@ import { commonTestsSuiteRTL } from '@lumx/react/testing/utils';
4
4
  import { queryByRole, render } from '@testing-library/react';
5
5
  import { getByClassName, queryByClassName } from '@lumx/react/testing/utils/queries';
6
6
  import userEvent from '@testing-library/user-event';
7
+ import { isFocusVisible } from '@lumx/react/utils/isFocusVisible';
7
8
 
8
9
  import { ExpansionPanel, ExpansionPanelProps } from '.';
9
10
 
10
11
  const CLASSNAME = ExpansionPanel.className as string;
11
12
 
13
+ jest.mock('@lumx/react/utils/isFocusVisible');
14
+
12
15
  /**
13
16
  * Mounts the component and returns common DOM elements / data needed in multiple tests further down.
14
17
  */
@@ -32,6 +35,8 @@ const setup = (propsOverride: Partial<ExpansionPanelProps> = {}) => {
32
35
  };
33
36
 
34
37
  describe(`<${ExpansionPanel.displayName}>`, () => {
38
+ (isFocusVisible as jest.Mock).mockReturnValue(false);
39
+
35
40
  describe('Render', () => {
36
41
  it('should render default', () => {
37
42
  const { element, query } = setup();
@@ -41,18 +41,18 @@ const DEFAULT_PROPS: Partial<InputHelperProps> = {
41
41
  * @param ref Component ref.
42
42
  * @return React element.
43
43
  */
44
- export const InputHelper: Comp<InputHelperProps, HTMLSpanElement> = forwardRef((props, ref) => {
44
+ export const InputHelper: Comp<InputHelperProps, HTMLParagraphElement> = forwardRef((props, ref) => {
45
45
  const { children, className, kind, theme, ...forwardedProps } = props;
46
46
  const { color } = INPUT_HELPER_CONFIGURATION[kind as any] || {};
47
47
 
48
48
  return (
49
- <span
49
+ <p
50
50
  ref={ref}
51
51
  {...forwardedProps}
52
52
  className={classNames(className, handleBasicClasses({ prefix: CLASSNAME, color, theme }))}
53
53
  >
54
54
  {children}
55
- </span>
55
+ </p>
56
56
  );
57
57
  });
58
58
 
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, ReactNode, RefObject, useCallback, useRef } from 'react';
1
+ import React, { forwardRef, ReactNode, RefObject, useRef } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
 
4
4
  import classNames from 'classnames';
@@ -9,14 +9,13 @@ import { ClickAwayProvider } from '@lumx/react/utils/ClickAwayProvider';
9
9
  import { DOCUMENT } from '@lumx/react/constants';
10
10
  import { Comp, GenericProps, HasTheme } from '@lumx/react/utils/type';
11
11
  import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
12
- import { mergeRefs } from '@lumx/react/utils/mergeRefs';
13
- import { useFocusWithin } from '@lumx/react/hooks/useFocusWithin';
14
- import { getFirstAndLastFocusable } from '@lumx/react/utils/focus/getFirstAndLastFocusable';
12
+ import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
15
13
  import { useFocusTrap } from '@lumx/react/hooks/useFocusTrap';
16
14
  import { skipRender } from '@lumx/react/utils/skipRender';
17
15
 
16
+ import { useRestoreFocusOnClose } from './useRestoreFocusOnClose';
18
17
  import { usePopoverStyle } from './usePopoverStyle';
19
- import { FitAnchorWidth, Elevation, Offset, Placement } from './constants';
18
+ import { Elevation, FitAnchorWidth, Offset, Placement } from './constants';
20
19
 
21
20
  /**
22
21
  * Defines the props of the component.
@@ -85,6 +84,7 @@ const CLASSNAME = getRootClassName(COMPONENT_NAME);
85
84
  const DEFAULT_PROPS: Partial<PopoverProps> = {
86
85
  elevation: 3,
87
86
  placement: Placement.AUTO,
87
+ focusAnchorOnClose: true,
88
88
  usePortal: true,
89
89
  zIndex: 9999,
90
90
  };
@@ -110,7 +110,7 @@ const _InnerPopover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, ref
110
110
  onClose,
111
111
  parentElement,
112
112
  usePortal,
113
- focusAnchorOnClose = true,
113
+ focusAnchorOnClose,
114
114
  withFocusTrap,
115
115
  boundaryRef,
116
116
  fitToAnchorWidth,
@@ -122,8 +122,7 @@ const _InnerPopover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, ref
122
122
  zIndex,
123
123
  ...forwardedProps
124
124
  } = props;
125
- const clickAwayRef = useRef<HTMLDivElement>(null);
126
- const contentRef = useRef<HTMLDivElement>(null);
125
+ const popoverRef = useRef<HTMLDivElement>(null);
127
126
 
128
127
  const {
129
128
  styles,
@@ -146,62 +145,22 @@ const _InnerPopover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, ref
146
145
  zIndex,
147
146
  });
148
147
 
149
- /**
150
- * Track whether the focus is currently set in the
151
- * popover.
152
- * */
153
- const isFocusedWithin = useRef(false);
154
-
155
- useFocusWithin({
156
- element: popperElement || null,
157
- onFocusIn: () => {
158
- isFocusedWithin.current = true;
159
- },
160
- onFocusOut: () => {
161
- isFocusedWithin.current = false;
162
- },
163
- });
148
+ const unmountSentinel = useRestoreFocusOnClose({ focusAnchorOnClose, anchorRef, parentElement }, popperElement);
164
149
 
165
- /** Action on close */
166
- const handleClose = useCallback(() => {
167
- if (!onClose) {
168
- return;
169
- }
170
-
171
- /**
172
- * If the focus is currently within the popover
173
- * when the popover closes, reset the focus back to the anchor element
174
- * unless specifically requested not to.
175
- */
176
- if (isFocusedWithin.current && focusAnchorOnClose) {
177
- let elementToFocus = parentElement?.current;
178
- if (!elementToFocus && anchorRef?.current) {
179
- // Focus the first focusable element in anchor.
180
- elementToFocus = getFirstAndLastFocusable(anchorRef.current).first;
181
- }
182
- if (!elementToFocus) {
183
- // Fallback on the anchor element.
184
- elementToFocus = anchorRef?.current;
185
- }
186
- elementToFocus?.focus({ preventScroll: true });
187
- }
188
-
189
- onClose();
190
- }, [anchorRef, focusAnchorOnClose, onClose, parentElement]);
191
-
192
- useCallbackOnEscape(handleClose, isOpen && closeOnEscape);
150
+ useCallbackOnEscape(onClose, isOpen && closeOnEscape);
193
151
 
194
152
  /** Only set focus within if the focus trap is disabled as they interfere with one another. */
195
153
  useFocus(focusElement?.current, !withFocusTrap && isOpen && isPositioned);
196
- useFocusTrap(withFocusTrap && isOpen && contentRef?.current, focusElement?.current);
154
+ useFocusTrap(withFocusTrap && isOpen && popoverRef?.current, focusElement?.current);
197
155
 
198
- const clickAwayRefs = useRef([clickAwayRef, anchorRef]);
156
+ const clickAwayRefs = useRef([popoverRef, anchorRef]);
157
+ const mergedRefs = useMergeRefs<HTMLDivElement>(setPopperElement, ref, popoverRef);
199
158
 
200
159
  return isOpen
201
160
  ? renderPopover(
202
161
  <Component
203
162
  {...forwardedProps}
204
- ref={mergeRefs<HTMLDivElement>(setPopperElement, ref, clickAwayRef, contentRef)}
163
+ ref={mergedRefs}
205
164
  className={classNames(
206
165
  className,
207
166
  handleBasicClasses({
@@ -214,7 +173,8 @@ const _InnerPopover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, ref
214
173
  style={styles.popover}
215
174
  {...attributes.popper}
216
175
  >
217
- <ClickAwayProvider callback={closeOnClickAway && handleClose} childrenRefs={clickAwayRefs}>
176
+ {unmountSentinel}
177
+ <ClickAwayProvider callback={closeOnClickAway && onClose} childrenRefs={clickAwayRefs}>
218
178
  {hasArrow && (
219
179
  <div ref={setArrowElement} className={`${CLASSNAME}__arrow`} style={styles.arrow}>
220
180
  <svg viewBox="0 0 14 14" aria-hidden>
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { getFirstAndLastFocusable } from '@lumx/react/utils/focus/getFirstAndLastFocusable';
3
+ import { useBeforeUnmountSentinel } from '@lumx/react/utils/useBeforeUnmountSentinel';
4
+ import type { PopoverProps } from './Popover';
5
+
6
+ /**
7
+ * Provides an unmount sentinel to inject in the popover to detect when it closes and restore the focus to the
8
+ * anchor if needed.
9
+ */
10
+ export function useRestoreFocusOnClose(
11
+ props: Pick<PopoverProps, 'focusAnchorOnClose' | 'anchorRef' | 'parentElement'>,
12
+ popoverElement?: HTMLElement | null,
13
+ ) {
14
+ const onBeforeUnmount = React.useMemo(() => {
15
+ if (!popoverElement || !props.focusAnchorOnClose) return undefined;
16
+ return () => {
17
+ const isFocusWithin = popoverElement?.contains(document.activeElement);
18
+ if (!isFocusWithin) return;
19
+
20
+ const anchor = props.anchorRef.current;
21
+ const elementToFocus =
22
+ // Provided parent element
23
+ props.parentElement?.current ||
24
+ // Or first focusable element in anchor
25
+ (anchor ? getFirstAndLastFocusable(anchor).first : undefined) ||
26
+ // Fallback to anchor
27
+ anchor;
28
+
29
+ elementToFocus?.focus({ preventScroll: true });
30
+ };
31
+ }, [popoverElement, props.anchorRef, props.focusAnchorOnClose, props.parentElement]);
32
+ return useBeforeUnmountSentinel(onBeforeUnmount);
33
+ }
@@ -7,11 +7,13 @@ import { getByClassName, queryAllByClassName, queryByClassName } from '@lumx/rea
7
7
  import { render, within } from '@testing-library/react';
8
8
  import { commonTestsSuiteRTL } from '@lumx/react/testing/utils';
9
9
  import userEvent from '@testing-library/user-event';
10
+ import { isFocusVisible } from '@lumx/react/utils/isFocusVisible';
10
11
 
11
12
  import { Select, SelectProps, SelectVariant } from './Select';
12
13
 
13
14
  const CLASSNAME = Select.className as string;
14
15
 
16
+ jest.mock('@lumx/react/utils/isFocusVisible');
15
17
  jest.mock('uid', () => ({ uid: () => 'uid' }));
16
18
 
17
19
  /**
@@ -33,6 +35,8 @@ const setup = (propsOverride: Partial<SelectProps> = {}) => {
33
35
  };
34
36
 
35
37
  describe(`<${Select.displayName}>`, () => {
38
+ (isFocusVisible as jest.Mock).mockReturnValue(false);
39
+
36
40
  describe('Props', () => {
37
41
  it('should have default classNames', () => {
38
42
  const { select, getDropdown } = setup();
@@ -15,10 +15,13 @@ import {
15
15
  import partition from 'lodash/partition';
16
16
  import userEvent from '@testing-library/user-event';
17
17
 
18
+ import { isFocusVisible } from '@lumx/react/utils/isFocusVisible';
18
19
  import { TextField, TextFieldProps } from './TextField';
19
20
 
20
21
  const CLASSNAME = TextField.className as string;
21
22
 
23
+ jest.mock('@lumx/react/utils/isFocusVisible');
24
+
22
25
  /**
23
26
  * Mounts the component and returns common DOM elements / data needed in multiple tests further down.
24
27
  */
@@ -46,6 +49,8 @@ const setup = (propsOverride: Partial<TextFieldProps> = {}) => {
46
49
  };
47
50
 
48
51
  describe(`<${TextField.displayName}>`, () => {
52
+ (isFocusVisible as jest.Mock).mockReturnValue(false);
53
+
49
54
  describe('Render', () => {
50
55
  it('should render defaults', () => {
51
56
  const { element, inputNative } = setup({ id: 'fixedId' });
@@ -86,3 +86,19 @@ export const HideTooltipOnHiddenAnchor = () => {
86
86
  </>
87
87
  );
88
88
  };
89
+ HideTooltipOnHiddenAnchor.parameters = { chromatic: { disableSnapshot: true } };
90
+
91
+ /** Test focusing a tooltip anchor programmatically */
92
+ export const TestProgrammaticFocus = () => {
93
+ const anchorRef = React.useRef<HTMLButtonElement>(null);
94
+ return (
95
+ <>
96
+ <p>The tooltip should open on keyboard focus but not on programmatic focus (ex: after a click)</p>
97
+ <Tooltip label="label">
98
+ <Button ref={anchorRef}>button with label</Button>
99
+ </Tooltip>
100
+ <Button onClick={() => anchorRef.current?.focus()}>focus the button</Button>
101
+ </>
102
+ );
103
+ };
104
+ TestProgrammaticFocus.parameters = { chromatic: { disableSnapshot: true } };
@@ -6,10 +6,12 @@ import { queryAllByTagName, queryByClassName } from '@lumx/react/testing/utils/q
6
6
  import { commonTestsSuiteRTL } from '@lumx/react/testing/utils';
7
7
  import userEvent from '@testing-library/user-event';
8
8
 
9
+ import { isFocusVisible } from '@lumx/react/utils/isFocusVisible';
9
10
  import { Tooltip, TooltipProps } from './Tooltip';
10
11
 
11
12
  const CLASSNAME = Tooltip.className as string;
12
13
 
14
+ jest.mock('@lumx/react/utils/isFocusVisible');
13
15
  jest.mock('uid', () => ({ uid: () => 'uid' }));
14
16
  // Skip delays
15
17
  jest.mock('@lumx/react/constants', () => ({
@@ -148,7 +150,8 @@ describe(`<${Tooltip.displayName}>`, () => {
148
150
  });
149
151
  });
150
152
 
151
- it('should activate on anchor focus and close on escape', async () => {
153
+ it('should activate on anchor focus visible and close on escape', async () => {
154
+ (isFocusVisible as jest.Mock).mockReturnValue(true);
152
155
  let { tooltip } = await setup({
153
156
  label: 'Tooltip label',
154
157
  children: <Button>Anchor</Button>,
@@ -181,6 +184,26 @@ describe(`<${Tooltip.displayName}>`, () => {
181
184
  await userEvent.keyboard('{Escape}');
182
185
  expect(tooltip).not.toBeInTheDocument();
183
186
  });
187
+
188
+ it('should not activate on anchor focus if not visible', async () => {
189
+ (isFocusVisible as jest.Mock).mockReturnValue(false);
190
+ let { tooltip } = await setup({
191
+ label: 'Tooltip label',
192
+ children: <Button>Anchor</Button>,
193
+ forceOpen: false,
194
+ });
195
+
196
+ expect(tooltip).not.toBeInTheDocument();
197
+
198
+ // Focus anchor button
199
+ await userEvent.tab();
200
+ const button = screen.getByRole('button', { name: 'Anchor' });
201
+ expect(button).toHaveFocus();
202
+
203
+ // Tooltip not opening
204
+ tooltip = screen.queryByRole('tooltip', { name: 'Tooltip label' });
205
+ expect(tooltip).not.toBeInTheDocument();
206
+ });
184
207
  });
185
208
 
186
209
  // Common tests suite.
@@ -2,6 +2,7 @@ import { MutableRefObject, useEffect, useRef, useState } from 'react';
2
2
  import { browserDoesNotSupportHover } from '@lumx/react/utils/browserDoesNotSupportHover';
3
3
  import { TOOLTIP_HOVER_DELAY, TOOLTIP_LONG_PRESS_DELAY } from '@lumx/react/constants';
4
4
  import { useCallbackOnEscape } from '@lumx/react/hooks/useCallbackOnEscape';
5
+ import { isFocusVisible } from '@lumx/react/utils/isFocusVisible';
5
6
 
6
7
  /**
7
8
  * Hook controlling tooltip visibility using mouse hover the anchor and delay.
@@ -106,8 +107,16 @@ export function useTooltipOpen(delay: number | undefined, anchorElement: HTMLEle
106
107
 
107
108
  // Events always applied no matter the browser:.
108
109
  events.push(
109
- // Open on focus.
110
- [anchorElement, 'focusin', open],
110
+ // Open on focus (only if focus is visible).
111
+ [
112
+ anchorElement,
113
+ 'focusin',
114
+ (e: Event) => {
115
+ // Skip if focus is not visible
116
+ if (!isFocusVisible(e.target as HTMLElement)) return;
117
+ open();
118
+ },
119
+ ],
111
120
  // Close on lost focus.
112
121
  [anchorElement, 'focusout', closeImmediately],
113
122
  );
@@ -0,0 +1,3 @@
1
+ /** Check if the focus is visible on the given element */
2
+ export const isFocusVisible = (element?: HTMLElement) =>
3
+ element?.matches?.(':focus-visible, [data-focus-visible-added]');
@@ -1,5 +1,5 @@
1
1
  import { Falsy } from '@lumx/react/utils/type';
2
- import { MutableRefObject } from 'react';
2
+ import { MutableRefObject, useMemo } from 'react';
3
3
 
4
4
  type FnRef<T> = (value: T) => void;
5
5
 
@@ -20,3 +20,14 @@ export function mergeRefs<T>(...refs: Array<MutableRefObject<T | null> | FnRef<T
20
20
  }
21
21
  });
22
22
  }
23
+
24
+ /**
25
+ * Same as `mergeRefs` but memoized
26
+ */
27
+ export const useMergeRefs = <T>(...refs: Array<MutableRefObject<T | null> | FnRef<T> | Falsy>) => {
28
+ return useMemo(
29
+ () => mergeRefs(...refs),
30
+ // eslint-disable-next-line react-hooks/exhaustive-deps
31
+ refs,
32
+ );
33
+ };
@@ -0,0 +1,17 @@
1
+ import React, { useLayoutEffect } from 'react';
2
+
3
+ /** Small helper component using useLayoutEffect to trigger a callback on before unmount. */
4
+ const OnUnmount = ({ onBeforeUnmount }: { onBeforeUnmount: () => void }) => {
5
+ useLayoutEffect(() => onBeforeUnmount, [onBeforeUnmount]);
6
+ return null;
7
+ };
8
+
9
+ /**
10
+ * Provides a sentinel to inject the React tree that triggers the callback on before unmount.
11
+ */
12
+ export function useBeforeUnmountSentinel(onBeforeUnmount?: () => void) {
13
+ return React.useMemo(() => {
14
+ if (!onBeforeUnmount) return undefined;
15
+ return <OnUnmount onBeforeUnmount={onBeforeUnmount} />;
16
+ }, [onBeforeUnmount]);
17
+ }