@lumx/react 3.6.3 → 3.6.5-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.
- package/index.d.ts +1 -1
- package/index.js +138 -102
- package/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/button/IconButton.tsx +1 -8
- package/src/components/expansion-panel/ExpansionPanel.test.tsx +5 -0
- package/src/components/input-helper/InputHelper.tsx +3 -3
- package/src/components/popover/Popover.tsx +15 -55
- package/src/components/popover/useRestoreFocusOnClose.tsx +33 -0
- package/src/components/popover-dialog/PopoverDialog.stories.tsx +28 -1
- package/src/components/popover-dialog/PopoverDialog.test.tsx +31 -0
- package/src/components/select/Select.test.tsx +4 -0
- package/src/components/text-field/TextField.test.tsx +5 -0
- package/src/components/tooltip/Tooltip.stories.tsx +16 -0
- package/src/components/tooltip/Tooltip.test.tsx +54 -2
- package/src/components/tooltip/Tooltip.tsx +1 -1
- package/src/components/tooltip/useInjectTooltipRef.tsx +18 -22
- package/src/components/tooltip/useTooltipOpen.tsx +11 -2
- package/src/utils/isFocusVisible.ts +3 -0
- package/src/utils/mergeRefs.ts +12 -1
- package/src/utils/useBeforeUnmountSentinel.tsx +17 -0
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.
|
|
11
|
-
"@lumx/icons": "^3.6.
|
|
10
|
+
"@lumx/core": "^3.6.5-alpha.0",
|
|
11
|
+
"@lumx/icons": "^3.6.5-alpha.0",
|
|
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.
|
|
115
|
+
"version": "3.6.5-alpha.0"
|
|
116
116
|
}
|
|
@@ -61,14 +61,7 @@ export const IconButton: Comp<IconButtonProps, HTMLButtonElement> = forwardRef((
|
|
|
61
61
|
|
|
62
62
|
return (
|
|
63
63
|
<Tooltip label={hideTooltip ? '' : label} {...tooltipProps}>
|
|
64
|
-
<ButtonRoot
|
|
65
|
-
ref={ref}
|
|
66
|
-
{...{ emphasis, size, theme, ...forwardedProps }}
|
|
67
|
-
aria-label={label}
|
|
68
|
-
variant="icon"
|
|
69
|
-
// Remove the aria-describedby added by the tooltip when it is the same text as the aria-label
|
|
70
|
-
aria-describedby={tooltipProps?.label && tooltipProps?.label === label && undefined}
|
|
71
|
-
>
|
|
64
|
+
<ButtonRoot ref={ref} {...{ emphasis, size, theme, ...forwardedProps }} aria-label={label} variant="icon">
|
|
72
65
|
{image ? (
|
|
73
66
|
<img
|
|
74
67
|
// no need to set alt as an aria-label is already set on the button
|
|
@@ -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,
|
|
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
|
-
<
|
|
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
|
-
</
|
|
55
|
+
</p>
|
|
56
56
|
);
|
|
57
57
|
});
|
|
58
58
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { forwardRef, ReactNode, RefObject,
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
154
|
+
useFocusTrap(withFocusTrap && isOpen && popoverRef?.current, focusElement?.current);
|
|
197
155
|
|
|
198
|
-
const clickAwayRefs = useRef([
|
|
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={
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { useBooleanState } from '@lumx/react/hooks/useBooleanState';
|
|
3
|
+
import { mdiMenuDown } from '@lumx/icons';
|
|
3
4
|
import { PopoverDialog } from '.';
|
|
4
|
-
import { Button } from '../button';
|
|
5
|
+
import { Button, IconButton } from '../button';
|
|
5
6
|
|
|
6
7
|
export default {
|
|
7
8
|
title: 'LumX components/popover-dialog/PopoverDialog',
|
|
8
9
|
component: PopoverDialog,
|
|
10
|
+
parameters: { chromatic: { disableSnapshot: true } },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Example PopoverDialog using an IconButton as a trigger
|
|
15
|
+
*/
|
|
16
|
+
export const WithIconButtonTrigger = () => {
|
|
17
|
+
const anchorRef = React.useRef(null);
|
|
18
|
+
const [isOpen, close, open] = useBooleanState(false);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<IconButton id="trigger-button-1" label="Open popover" ref={anchorRef} onClick={open} icon={mdiMenuDown} />
|
|
23
|
+
<PopoverDialog
|
|
24
|
+
aria-labelledby="trigger-button-1"
|
|
25
|
+
anchorRef={anchorRef}
|
|
26
|
+
isOpen={isOpen}
|
|
27
|
+
onClose={close}
|
|
28
|
+
placement="bottom"
|
|
29
|
+
>
|
|
30
|
+
<Button className="lumx-spacing-margin-huge" onClick={close}>
|
|
31
|
+
Close
|
|
32
|
+
</Button>
|
|
33
|
+
</PopoverDialog>
|
|
34
|
+
</>
|
|
35
|
+
);
|
|
9
36
|
};
|
|
10
37
|
|
|
11
38
|
/**
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import React, { useRef, useState } from 'react';
|
|
2
2
|
import { render, screen, within } from '@testing-library/react';
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { isFocusVisible } from '@lumx/react/utils/isFocusVisible';
|
|
4
5
|
|
|
6
|
+
import { WithIconButtonTrigger } from './PopoverDialog.stories';
|
|
5
7
|
import { PopoverDialog } from './PopoverDialog';
|
|
6
8
|
|
|
9
|
+
jest.mock('@lumx/react/utils/isFocusVisible');
|
|
10
|
+
|
|
7
11
|
const DialogWithButton = (forwardedProps: any) => {
|
|
8
12
|
const anchorRef = useRef(null);
|
|
9
13
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
|
@@ -25,6 +29,8 @@ const DialogWithButton = (forwardedProps: any) => {
|
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
describe(`<${PopoverDialog.displayName}>`, () => {
|
|
32
|
+
(isFocusVisible as jest.Mock).mockReturnValue(false);
|
|
33
|
+
|
|
28
34
|
it('should behave like a dialog', async () => {
|
|
29
35
|
const label = 'Test Label';
|
|
30
36
|
|
|
@@ -62,4 +68,29 @@ describe(`<${PopoverDialog.displayName}>`, () => {
|
|
|
62
68
|
/** Anchor should retrieve the focus */
|
|
63
69
|
expect(triggerElement).toHaveFocus();
|
|
64
70
|
});
|
|
71
|
+
|
|
72
|
+
it('should work on icon button', async () => {
|
|
73
|
+
const label = 'Open popover';
|
|
74
|
+
render(<WithIconButtonTrigger />);
|
|
75
|
+
|
|
76
|
+
/** Open the popover */
|
|
77
|
+
const triggerElement = screen.getByRole('button', { name: label });
|
|
78
|
+
await userEvent.click(triggerElement);
|
|
79
|
+
|
|
80
|
+
const dialog = await screen.findByRole('dialog', { name: label });
|
|
81
|
+
const withinDialog = within(dialog);
|
|
82
|
+
|
|
83
|
+
/** Get buttons within dialog */
|
|
84
|
+
const dialogButtons = withinDialog.getAllByRole('button');
|
|
85
|
+
|
|
86
|
+
// First button should have focus by default on opening
|
|
87
|
+
expect(dialogButtons[0]).toHaveFocus();
|
|
88
|
+
|
|
89
|
+
// Close the popover
|
|
90
|
+
await userEvent.keyboard('{escape}');
|
|
91
|
+
|
|
92
|
+
expect(screen.queryByRole('dialog', { name: label })).not.toBeInTheDocument();
|
|
93
|
+
/** Anchor should retrieve the focus */
|
|
94
|
+
expect(triggerElement).toHaveFocus();
|
|
95
|
+
});
|
|
65
96
|
});
|
|
@@ -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 } };
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
|
-
import { Button } from '@lumx/react';
|
|
3
|
+
import { Button, IconButton } from '@lumx/react';
|
|
4
4
|
import { screen, render, waitFor } 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
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', () => ({
|
|
@@ -62,6 +64,35 @@ describe(`<${Tooltip.displayName}>`, () => {
|
|
|
62
64
|
expect(button).toHaveAttribute('aria-describedby', tooltip?.id);
|
|
63
65
|
});
|
|
64
66
|
|
|
67
|
+
it('should not add aria-describedby if button label is the same as tooltip label', async () => {
|
|
68
|
+
const label = 'Tooltip label';
|
|
69
|
+
render(<IconButton label={label} tooltipProps={{ forceOpen: true }} />);
|
|
70
|
+
const tooltip = screen.queryByRole('tooltip', { name: label });
|
|
71
|
+
expect(tooltip).toBeInTheDocument();
|
|
72
|
+
const button = screen.queryByRole('button', { name: label });
|
|
73
|
+
expect(button).not.toHaveAttribute('aria-describedby');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should keep anchor aria-describedby if button label is the same as tooltip label', async () => {
|
|
77
|
+
const label = 'Tooltip label';
|
|
78
|
+
render(<IconButton label={label} aria-describedby=":header-1:" tooltipProps={{ forceOpen: true }} />);
|
|
79
|
+
const tooltip = screen.queryByRole('tooltip', { name: label });
|
|
80
|
+
expect(tooltip).toBeInTheDocument();
|
|
81
|
+
const button = screen.queryByRole('button', { name: label });
|
|
82
|
+
expect(button).toHaveAttribute('aria-describedby', ':header-1:');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should concat aria-describedby if already exists', async () => {
|
|
86
|
+
const { tooltip } = await setup({
|
|
87
|
+
label: 'Tooltip label',
|
|
88
|
+
children: <Button aria-describedby=":header-1:">Anchor</Button>,
|
|
89
|
+
forceOpen: true,
|
|
90
|
+
});
|
|
91
|
+
expect(tooltip).toBeInTheDocument();
|
|
92
|
+
const button = screen.queryByRole('button', { name: 'Anchor' });
|
|
93
|
+
expect(button).toHaveAttribute('aria-describedby', `:header-1: ${tooltip?.id}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
65
96
|
it('should wrap disabled Button', async () => {
|
|
66
97
|
const { tooltip, anchorWrapper } = await setup({
|
|
67
98
|
label: 'Tooltip label',
|
|
@@ -148,7 +179,8 @@ describe(`<${Tooltip.displayName}>`, () => {
|
|
|
148
179
|
});
|
|
149
180
|
});
|
|
150
181
|
|
|
151
|
-
it('should activate on anchor focus and close on escape', async () => {
|
|
182
|
+
it('should activate on anchor focus visible and close on escape', async () => {
|
|
183
|
+
(isFocusVisible as jest.Mock).mockReturnValue(true);
|
|
152
184
|
let { tooltip } = await setup({
|
|
153
185
|
label: 'Tooltip label',
|
|
154
186
|
children: <Button>Anchor</Button>,
|
|
@@ -181,6 +213,26 @@ describe(`<${Tooltip.displayName}>`, () => {
|
|
|
181
213
|
await userEvent.keyboard('{Escape}');
|
|
182
214
|
expect(tooltip).not.toBeInTheDocument();
|
|
183
215
|
});
|
|
216
|
+
|
|
217
|
+
it('should not activate on anchor focus if not visible', async () => {
|
|
218
|
+
(isFocusVisible as jest.Mock).mockReturnValue(false);
|
|
219
|
+
let { tooltip } = await setup({
|
|
220
|
+
label: 'Tooltip label',
|
|
221
|
+
children: <Button>Anchor</Button>,
|
|
222
|
+
forceOpen: false,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(tooltip).not.toBeInTheDocument();
|
|
226
|
+
|
|
227
|
+
// Focus anchor button
|
|
228
|
+
await userEvent.tab();
|
|
229
|
+
const button = screen.getByRole('button', { name: 'Anchor' });
|
|
230
|
+
expect(button).toHaveFocus();
|
|
231
|
+
|
|
232
|
+
// Tooltip not opening
|
|
233
|
+
tooltip = screen.queryByRole('tooltip', { name: 'Tooltip label' });
|
|
234
|
+
expect(tooltip).not.toBeInTheDocument();
|
|
235
|
+
});
|
|
184
236
|
});
|
|
185
237
|
|
|
186
238
|
// Common tests suite.
|
|
@@ -87,7 +87,7 @@ export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, re
|
|
|
87
87
|
const position = attributes?.popper?.['data-popper-placement'] ?? placement;
|
|
88
88
|
const { isOpen: isActivated, onPopperMount } = useTooltipOpen(delay, anchorElement);
|
|
89
89
|
const isOpen = isActivated || forceOpen;
|
|
90
|
-
const wrappedChildren = useInjectTooltipRef(children, setAnchorElement, isOpen, id);
|
|
90
|
+
const wrappedChildren = useInjectTooltipRef(children, setAnchorElement, isOpen, id, label);
|
|
91
91
|
|
|
92
92
|
return (
|
|
93
93
|
<>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import get from 'lodash/get';
|
|
2
|
-
import isUndefined from 'lodash/isUndefined';
|
|
3
1
|
import React, { cloneElement, ReactNode, useMemo } from 'react';
|
|
4
2
|
|
|
5
|
-
import {
|
|
3
|
+
import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
6
|
* Add ref and ARIA attribute(s) in tooltip children or wrapped children.
|
|
@@ -13,6 +11,7 @@ import { mergeRefs } from '@lumx/react/utils/mergeRefs';
|
|
|
13
11
|
* @param setAnchorElement Set tooltip anchor element.
|
|
14
12
|
* @param isOpen Whether the tooltip is open or not.
|
|
15
13
|
* @param id Tooltip id.
|
|
14
|
+
* @param label Tooltip label.
|
|
16
15
|
* @return tooltip anchor.
|
|
17
16
|
*/
|
|
18
17
|
export const useInjectTooltipRef = (
|
|
@@ -20,32 +19,29 @@ export const useInjectTooltipRef = (
|
|
|
20
19
|
setAnchorElement: (e: HTMLDivElement) => void,
|
|
21
20
|
isOpen: boolean | undefined,
|
|
22
21
|
id: string,
|
|
22
|
+
label: string,
|
|
23
23
|
): ReactNode => {
|
|
24
|
+
const element = React.isValidElement(children) ? (children as any) : null;
|
|
25
|
+
const ref = useMergeRefs(element?.ref, setAnchorElement);
|
|
26
|
+
|
|
24
27
|
return useMemo(() => {
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
: false;
|
|
29
|
-
const ariaProps = { 'aria-describedby': isOpen && !childrenHasAriaProp ? id : undefined };
|
|
28
|
+
// Non-disabled element
|
|
29
|
+
if (element && element.props?.disabled !== true && element.props?.isDisabled !== true) {
|
|
30
|
+
const props = { ...element.props, ref };
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
get(children, 'props.isDisabled') !== true
|
|
36
|
-
) {
|
|
37
|
-
const element = children as any;
|
|
32
|
+
// Add current tooltip to the aria-describedby if the label is not already present
|
|
33
|
+
if (label !== props['aria-label']) {
|
|
34
|
+
props['aria-describedby'] = [props['aria-describedby'], id].filter(Boolean).join(' ');
|
|
35
|
+
}
|
|
38
36
|
|
|
39
|
-
return cloneElement(element,
|
|
40
|
-
...element.props,
|
|
41
|
-
...ariaProps,
|
|
42
|
-
ref: mergeRefs(element.ref, setAnchorElement),
|
|
43
|
-
});
|
|
37
|
+
return cloneElement(element, props);
|
|
44
38
|
}
|
|
39
|
+
|
|
40
|
+
// Else add a wrapper around the children
|
|
45
41
|
return (
|
|
46
|
-
<div className="lumx-tooltip-anchor-wrapper" ref={
|
|
42
|
+
<div className="lumx-tooltip-anchor-wrapper" ref={ref} aria-describedby={isOpen ? id : undefined}>
|
|
47
43
|
{children}
|
|
48
44
|
</div>
|
|
49
45
|
);
|
|
50
|
-
}, [isOpen, id,
|
|
46
|
+
}, [element, children, setAnchorElement, isOpen, id, ref, label]);
|
|
51
47
|
};
|
|
@@ -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
|
-
[
|
|
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
|
);
|
package/src/utils/mergeRefs.ts
CHANGED
|
@@ -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
|
+
}
|