@lumx/react 3.6.5-alpha.2 → 3.6.5-alpha.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.5-alpha.2",
11
- "@lumx/icons": "^3.6.5-alpha.2",
10
+ "@lumx/core": "^3.6.5-alpha.4",
11
+ "@lumx/icons": "^3.6.5-alpha.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.5-alpha.2"
115
+ "version": "3.6.5-alpha.4"
116
116
  }
@@ -133,6 +133,11 @@ export interface AutocompleteProps extends GenericProps, HasTheme {
133
133
  * @see {@link DropdownProps#closeOnEscape}
134
134
  */
135
135
  closeOnEscape?: boolean;
136
+ /**
137
+ * Whether the focus should go back on the anchor when dropdown closes and focus is within.
138
+ * @see {@link DropdownProps#focusAnchorOnClose}
139
+ */
140
+ focusAnchorOnClose?: DropdownProps['focusAnchorOnClose'];
136
141
  /**
137
142
  * The function called on blur.
138
143
  * @see {@link TextFieldProps#onBlur}
@@ -229,6 +234,7 @@ export const Autocomplete: Comp<AutocompleteProps, HTMLDivElement> = forwardRef(
229
234
  theme,
230
235
  value,
231
236
  textFieldProps = {},
237
+ focusAnchorOnClose,
232
238
  ...forwardedProps
233
239
  } = props;
234
240
  const inputAnchorRef = useRef<HTMLElement>(null);
@@ -273,6 +279,7 @@ export const Autocomplete: Comp<AutocompleteProps, HTMLDivElement> = forwardRef(
273
279
  closeOnClick={closeOnClick}
274
280
  closeOnClickAway={closeOnClickAway}
275
281
  closeOnEscape={closeOnEscape}
282
+ focusAnchorOnClose={focusAnchorOnClose}
276
283
  fitToAnchorWidth={fitToAnchorWidth}
277
284
  isOpen={isOpen}
278
285
  offset={offset}
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { getFirstAndLastFocusable } from '@lumx/react/utils/focus/getFirstAndLastFocusable';
3
- import { useBeforeUnmountSentinel } from '@lumx/react/utils/useBeforeUnmountSentinel';
3
+ import { OnBeforeUnmount } from '@lumx/react/utils/OnBeforeUnmount';
4
4
  import type { PopoverProps } from './Popover';
5
5
 
6
6
  /**
@@ -8,26 +8,38 @@ import type { PopoverProps } from './Popover';
8
8
  * anchor if needed.
9
9
  */
10
10
  export function useRestoreFocusOnClose(
11
- props: Pick<PopoverProps, 'focusAnchorOnClose' | 'anchorRef' | 'parentElement'>,
11
+ {
12
+ focusAnchorOnClose,
13
+ anchorRef,
14
+ parentElement,
15
+ }: Pick<PopoverProps, 'focusAnchorOnClose' | 'anchorRef' | 'parentElement'>,
12
16
  popoverElement?: HTMLElement | null,
13
17
  ) {
14
- const onBeforeUnmount = React.useMemo(() => {
15
- if (!popoverElement || !props.focusAnchorOnClose) return undefined;
16
- return () => {
18
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
19
+ const onBeforeUnmountRef = React.useRef<() => void>(() => {});
20
+
21
+ const anchor = anchorRef.current;
22
+ const elementToFocus =
23
+ // Provided parent element
24
+ parentElement?.current ||
25
+ // Or first focusable element in anchor
26
+ (anchor ? getFirstAndLastFocusable(anchor).first : undefined) ||
27
+ // Fallback to anchor
28
+ anchor;
29
+
30
+ React.useEffect(() => {
31
+ if (!popoverElement || !focusAnchorOnClose || !elementToFocus) return;
32
+
33
+ onBeforeUnmountRef.current = () => {
17
34
  const isFocusWithin = popoverElement?.contains(document.activeElement);
18
35
  if (!isFocusWithin) return;
19
36
 
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 });
37
+ // Focus on next render
38
+ setTimeout(() => {
39
+ elementToFocus.focus({ preventScroll: true });
40
+ }, 0);
30
41
  };
31
- }, [popoverElement, props.anchorRef, props.focusAnchorOnClose, props.parentElement]);
32
- return useBeforeUnmountSentinel(onBeforeUnmount);
42
+ }, [anchor, elementToFocus, focusAnchorOnClose, popoverElement]);
43
+
44
+ return <OnBeforeUnmount callback={onBeforeUnmountRef} />;
33
45
  }
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { useBooleanState } from '@lumx/react/hooks/useBooleanState';
3
- import { mdiMenuDown } from '@lumx/icons';
3
+ import { mdiMenuDown } from '@lumx/icons/';
4
4
  import { PopoverDialog } from '.';
5
5
  import { Button, IconButton } from '../button';
6
6
 
@@ -11,52 +11,52 @@ export default {
11
11
  };
12
12
 
13
13
  /**
14
- * Example PopoverDialog using an IconButton as a trigger
14
+ * Example PopoverDialog using a button as a trigger
15
15
  */
16
- export const WithIconButtonTrigger = () => {
16
+ export const WithButtonTrigger = (props: any) => {
17
17
  const anchorRef = React.useRef(null);
18
18
  const [isOpen, close, open] = useBooleanState(false);
19
19
 
20
20
  return (
21
21
  <>
22
- <IconButton id="trigger-button-1" label="Open popover" ref={anchorRef} onClick={open} icon={mdiMenuDown} />
22
+ <Button ref={anchorRef} onClick={open}>
23
+ Open popover
24
+ </Button>
23
25
  <PopoverDialog
24
- aria-labelledby="trigger-button-1"
25
26
  anchorRef={anchorRef}
26
27
  isOpen={isOpen}
27
28
  onClose={close}
28
29
  placement="bottom"
30
+ className="lumx-spacing-padding-huge"
31
+ {...props}
29
32
  >
30
- <Button className="lumx-spacing-margin-huge" onClick={close}>
31
- Close
32
- </Button>
33
+ <Button onClick={close}>Close</Button>
34
+ <Button emphasis="medium">Other button</Button>
33
35
  </PopoverDialog>
34
36
  </>
35
37
  );
36
38
  };
37
39
 
38
40
  /**
39
- * Example PopoverDialog using a button as a trigger
41
+ * Example PopoverDialog using an icon button as a trigger
40
42
  */
41
- export const WithButtonTrigger = () => {
43
+ export const WithIconButtonTrigger = (props: any) => {
42
44
  const anchorRef = React.useRef(null);
43
- const [isOpen, close, open] = useBooleanState(true);
45
+ const [isOpen, close, open] = useBooleanState(false);
44
46
 
45
47
  return (
46
48
  <>
47
- <Button id="trigger-button-1" ref={anchorRef} onClick={open}>
48
- Open popover
49
- </Button>
49
+ <IconButton label="Open popover" ref={anchorRef} onClick={open} icon={mdiMenuDown} />
50
50
  <PopoverDialog
51
- aria-labelledby="trigger-button-1"
52
51
  anchorRef={anchorRef}
53
52
  isOpen={isOpen}
54
53
  onClose={close}
55
54
  placement="bottom"
55
+ className="lumx-spacing-padding-huge"
56
+ label="Example popover"
57
+ {...props}
56
58
  >
57
- <Button className="lumx-spacing-margin-huge" onClick={close}>
58
- Close
59
- </Button>
59
+ <Button onClick={close}>Close</Button>
60
60
  </PopoverDialog>
61
61
  </>
62
62
  );
@@ -1,50 +1,38 @@
1
- import React, { useRef, useState } from 'react';
1
+ import React 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';
5
4
 
6
- import { WithIconButtonTrigger } from './PopoverDialog.stories';
7
5
  import { PopoverDialog } from './PopoverDialog';
6
+ import { WithButtonTrigger, WithIconButtonTrigger } from './PopoverDialog.stories';
8
7
 
9
8
  jest.mock('@lumx/react/utils/isFocusVisible');
10
9
 
11
- const DialogWithButton = (forwardedProps: any) => {
12
- const anchorRef = useRef(null);
13
- const [isOpen, setIsOpen] = useState<boolean>(false);
14
-
15
- return (
16
- <>
17
- <button type="button" ref={anchorRef} onClick={() => setIsOpen((current) => !current)}>
18
- Open popover
19
- </button>
20
-
21
- <PopoverDialog {...forwardedProps} anchorRef={anchorRef} isOpen={isOpen} onClose={() => setIsOpen(false)}>
22
- <button type="button">Button 1</button>
23
- <button type="button">Button 2</button>
24
- </PopoverDialog>
25
- {/* This should never have focus while popover is opened */}
26
- <button type="button">External button</button>
27
- </>
28
- );
29
- };
30
-
31
10
  describe(`<${PopoverDialog.displayName}>`, () => {
32
- (isFocusVisible as jest.Mock).mockReturnValue(false);
33
-
34
- it('should behave like a dialog', async () => {
11
+ it('should open and init focus', async () => {
35
12
  const label = 'Test Label';
13
+ render(<WithButtonTrigger label={label} />);
14
+
15
+ // Open popover
16
+ const triggerElement = screen.getByRole('button', { name: 'Open popover' });
17
+ await userEvent.click(triggerElement);
18
+
19
+ const dialog = await screen.findByRole('dialog', { name: label });
20
+
21
+ // Focused the first button
22
+ expect(within(dialog).getAllByRole('button')[0]).toHaveFocus();
23
+ });
36
24
 
37
- render(<DialogWithButton label={label} />);
25
+ it('should trap focus', async () => {
26
+ const label = 'Test Label';
27
+ render(<WithButtonTrigger label={label} />);
38
28
 
39
- /** Open the popover */
29
+ // Open popover
40
30
  const triggerElement = screen.getByRole('button', { name: 'Open popover' });
41
31
  await userEvent.click(triggerElement);
42
32
 
43
33
  const dialog = await screen.findByRole('dialog', { name: label });
44
- const withinDialog = within(dialog);
45
34
 
46
- /** Get buttons within dialog */
47
- const dialogButtons = withinDialog.getAllByRole('button');
35
+ const dialogButtons = within(dialog).getAllByRole('button');
48
36
 
49
37
  // First button should have focus by default on opening
50
38
  expect(dialogButtons[0]).toHaveFocus();
@@ -60,37 +48,62 @@ describe(`<${PopoverDialog.displayName}>`, () => {
60
48
 
61
49
  // As there is no more button, focus should loop back to first button.
62
50
  expect(dialogButtons[0]).toHaveFocus();
51
+ });
52
+
53
+ it('should close on escape and restore focus to trigger', async () => {
54
+ const label = 'Test Label';
55
+ render(<WithButtonTrigger label={label} />);
56
+
57
+ // Open popover
58
+ const triggerElement = screen.getByRole('button', { name: 'Open popover' });
59
+ await userEvent.click(triggerElement);
60
+
61
+ const dialog = await screen.findByRole('dialog', { name: label });
63
62
 
64
63
  // Close the popover
65
64
  await userEvent.keyboard('{escape}');
66
65
 
67
- expect(screen.queryByRole('dialog', { name: label })).not.toBeInTheDocument();
68
- /** Anchor should retrieve the focus */
66
+ expect(dialog).not.toBeInTheDocument();
67
+
68
+ // Focus restored to the trigger element
69
69
  expect(triggerElement).toHaveFocus();
70
70
  });
71
71
 
72
- it('should work on icon button', async () => {
73
- const label = 'Open popover';
74
- render(<WithIconButtonTrigger />);
72
+ it('should close externally and restore focus to trigger', async () => {
73
+ const label = 'Test Label';
74
+ render(<WithButtonTrigger label={label} />);
75
75
 
76
- /** Open the popover */
77
- const triggerElement = screen.getByRole('button', { name: label });
76
+ // Open popover
77
+ const triggerElement = screen.getByRole('button', { name: 'Open popover' });
78
78
  await userEvent.click(triggerElement);
79
79
 
80
80
  const dialog = await screen.findByRole('dialog', { name: label });
81
- const withinDialog = within(dialog);
82
81
 
83
- /** Get buttons within dialog */
84
- const dialogButtons = withinDialog.getAllByRole('button');
82
+ // Close the popover
83
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }));
85
84
 
86
- // First button should have focus by default on opening
87
- expect(dialogButtons[0]).toHaveFocus();
85
+ expect(dialog).not.toBeInTheDocument();
86
+
87
+ // Focus restored to the trigger element
88
+ expect(triggerElement).toHaveFocus();
89
+ });
90
+
91
+ it('should close on escape and restore focus to trigger having a tooltip', async () => {
92
+ const label = 'Test Label';
93
+ render(<WithIconButtonTrigger label={label} />);
94
+
95
+ // Open popover
96
+ const triggerElement = screen.getByRole('button', { name: 'Open popover' });
97
+ await userEvent.click(triggerElement);
98
+
99
+ const dialog = await screen.findByRole('dialog', { name: label });
88
100
 
89
101
  // Close the popover
90
102
  await userEvent.keyboard('{escape}');
91
103
 
92
- expect(screen.queryByRole('dialog', { name: label })).not.toBeInTheDocument();
93
- /** Anchor should retrieve the focus */
104
+ expect(dialog).not.toBeInTheDocument();
105
+
106
+ // Focus restored to the trigger element
94
107
  expect(triggerElement).toHaveFocus();
95
108
  });
96
109
  });
@@ -52,6 +52,25 @@ describe(`<${Tooltip.displayName}>`, () => {
52
52
  expect(anchorWrapper).toHaveAttribute('aria-describedby', tooltip?.id);
53
53
  });
54
54
 
55
+ it('should wrap unknown children and not add aria-describedby when closed', async () => {
56
+ const { anchorWrapper } = await setup({
57
+ label: 'Tooltip label',
58
+ children: 'Anchor',
59
+ forceOpen: false,
60
+ });
61
+ expect(anchorWrapper).not.toHaveAttribute('aria-describedby');
62
+ });
63
+
64
+ it('should not wrap Button and not add aria-describedby when closed', async () => {
65
+ await setup({
66
+ label: 'Tooltip label',
67
+ children: <Button>Anchor</Button>,
68
+ forceOpen: false,
69
+ });
70
+ const button = screen.queryByRole('button', { name: 'Anchor' });
71
+ expect(button).not.toHaveAttribute('aria-describedby');
72
+ });
73
+
55
74
  it('should not wrap Button', async () => {
56
75
  const { tooltip, anchorWrapper } = await setup({
57
76
  label: 'Tooltip label',
@@ -1,6 +1,6 @@
1
1
  import React, { cloneElement, ReactNode, useMemo } from 'react';
2
2
 
3
- import { useMergeRefs } from '@lumx/react/utils/mergeRefs';
3
+ import { mergeRefs } from '@lumx/react/utils/mergeRefs';
4
4
 
5
5
  /**
6
6
  * Add ref and ARIA attribute(s) in tooltip children or wrapped children.
@@ -21,27 +21,27 @@ export const useInjectTooltipRef = (
21
21
  id: string,
22
22
  label: string,
23
23
  ): ReactNode => {
24
- const element = React.isValidElement(children) ? (children as any) : null;
25
- const ref = useMergeRefs(element?.ref, setAnchorElement);
24
+ // Only add description when open
25
+ const describedBy = isOpen ? id : undefined;
26
26
 
27
27
  return useMemo(() => {
28
28
  // Non-disabled element
29
- if (element && element.props?.disabled !== true && element.props?.isDisabled !== true) {
30
- const props = { ...element.props, ref };
29
+ if (React.isValidElement(children) && children.props.disabled !== true && children.props.isDisabled !== true) {
30
+ const ref = mergeRefs((children as any).ref, setAnchorElement);
31
+ const props = { ...children.props, ref };
31
32
 
32
33
  // 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(' ');
34
+ if (label !== props['aria-label'] && describedBy) {
35
+ props['aria-describedby'] = [props['aria-describedby'], describedBy].filter(Boolean).join(' ');
35
36
  }
36
37
 
37
- return cloneElement(element, props);
38
+ return cloneElement(children, props);
38
39
  }
39
40
 
40
- // Else add a wrapper around the children
41
41
  return (
42
- <div className="lumx-tooltip-anchor-wrapper" ref={ref} aria-describedby={isOpen ? id : undefined}>
42
+ <div className="lumx-tooltip-anchor-wrapper" ref={setAnchorElement} aria-describedby={describedBy}>
43
43
  {children}
44
44
  </div>
45
45
  );
46
- }, [element, children, setAnchorElement, isOpen, id, ref, label]);
46
+ }, [children, setAnchorElement, describedBy, label]);
47
47
  };
@@ -0,0 +1,20 @@
1
+ import React, { useLayoutEffect } from 'react';
2
+
3
+ /**
4
+ * Helper component using useLayoutEffect to trigger a callback on before unmount.
5
+ *
6
+ * The callback must be wrapped in a React ref to avoid updating the `useLayoutEffect` before the un-mount
7
+ */
8
+ export const OnBeforeUnmount = ({ callback }: { callback: React.RefObject<() => void> }) => {
9
+ useLayoutEffect(
10
+ () => {
11
+ return () => {
12
+ // On unmount
13
+ // eslint-disable-next-line react-hooks/exhaustive-deps
14
+ callback.current?.();
15
+ };
16
+ }, // eslint-disable-next-line react-hooks/exhaustive-deps
17
+ [],
18
+ );
19
+ return null;
20
+ };
@@ -1,3 +1,9 @@
1
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]');
2
+ export const isFocusVisible = (element?: HTMLElement) => {
3
+ try {
4
+ return element?.matches?.(':focus-visible, [data-focus-visible-added]');
5
+ } catch (_ignored) {
6
+ // Can fail on non browser env
7
+ return true;
8
+ }
9
+ };
@@ -1,17 +0,0 @@
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
- }