Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8a71bc3
allow native event propagation
TheSonOfThomp Mar 18, 2025
95bf339
removes backdrop click mousedown handler
TheSonOfThomp Mar 18, 2025
9807f87
Update menu-trigger-propagation.md
TheSonOfThomp Mar 18, 2025
63d7925
Merge branch 'main' into a/menu-bug
TheSonOfThomp Mar 20, 2025
bebe792
Revert "removes backdrop click mousedown handler"
TheSonOfThomp Mar 20, 2025
5d1e0a7
updates menu tests
TheSonOfThomp Mar 20, 2025
e73c9e6
transition popover display property
TheSonOfThomp Mar 20, 2025
464180c
Adds onBeforeToggle to Popover
TheSonOfThomp Mar 21, 2025
5676204
Merge branch 'main' into a/menu-bug
TheSonOfThomp Mar 24, 2025
8e1e6c9
Merge branch 'main' into a/menu-bug
TheSonOfThomp Mar 27, 2025
82e5610
Update getPopoverRenderModeProps usage
TheSonOfThomp Mar 27, 2025
a5fec0d
cleanup GetPopoverRenderModePropsArgs types
TheSonOfThomp Mar 27, 2025
0ed2bb6
creates getClosestFocusableElement
TheSonOfThomp Mar 28, 2025
2a3e161
fix GetPopoverRenderModeProps export
TheSonOfThomp Mar 28, 2025
6b3a932
updates menu clickaway test
TheSonOfThomp Mar 28, 2025
4f1f09d
implement getClosestFocusableElement in Menu
TheSonOfThomp Mar 28, 2025
3a61853
Create getClosestFocusableElement.spec.ts
TheSonOfThomp Mar 28, 2025
10bd634
adds options to useBackdropClick
TheSonOfThomp Mar 28, 2025
0dcd22e
check if event is click
TheSonOfThomp Mar 28, 2025
87c82f9
add todo
TheSonOfThomp Mar 28, 2025
60bd64a
Update menu-trigger-propagation.md
TheSonOfThomp Mar 28, 2025
97211c1
Create backdrop-allow-propagation.md
TheSonOfThomp Mar 28, 2025
eff6644
Create closest-focusable-element.md
TheSonOfThomp Mar 28, 2025
9b2376e
reverts popover changes
TheSonOfThomp Mar 28, 2025
3126164
Merge branch 'main' into a/menu-bug
TheSonOfThomp Mar 28, 2025
64bb1e1
Merge branch 'main' into a/menu-bug
TheSonOfThomp Mar 31, 2025
87a8853
pr feedaback
TheSonOfThomp Mar 31, 2025
94909bb
add jira ticket
TheSonOfThomp Mar 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/backdrop-allow-propagation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@leafygreen-ui/hooks': minor
---

- Updates 3rd argument in `useBackdropClick` to accept an options object. Retains (but deprecates) boolean-only functionality.
- Adds `options.allowPropagation` to allow or disallow the click event to bubble up to other elements.
7 changes: 7 additions & 0 deletions .changeset/closest-focusable-element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@leafygreen-ui/lib': minor
---

Adds `getClosestFocusableElement`.
This function crawls up the DOM tree to find the closest focusable element to the provided element
Returns the element itself if it is focusable or if no focusable element is found, it returns the body element.
7 changes: 7 additions & 0 deletions .changeset/menu-trigger-propagation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@leafygreen-ui/menu': major
---

- Re-enables native event propagation on trigger clicks. Previously this was done to prevent errors caused by different event handling in React vs Backbone.
- Adds logic when the menu closes to check if the click occurred on an element that is focusable. If it is then we want to focus that element, otherwise we want to focus the menu trigger.
- Moves `popoverRef` from the `ul` to the root popover `div`
76 changes: 63 additions & 13 deletions packages/hooks/src/useBackdropClick.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import { consoleOnce } from '@leafygreen-ui/lib';

import useEventListener from './useEventListener';

interface UseBackdropClickOptions {
/**
* Whether the callback is enabled.
* It's recommended to set this to `false` when not in use,
* and toggle to `true` when the main elements (menu, tooltip, etc) are visible
*
* @default true
*/
enabled: boolean;

/**
* Allows the event to bubble up to other elements.
* When false, this ensures that only the `callback` is fired
* when the backdrop is clicked,
* (i.e. no other click event handlers are fired),
* and that the clicked element does not receive focus.
*
* To allow the event to propagate, set this to `true`,
* and ensure that you are correctly detecting whether the click target should receive focus,
* or whether focus should return to the popover trigger.
*
* @default false
*/
allowPropagation: boolean;
}

/**
* Fires a callback when any element(s)
* _except_ those passed in as `foreground` is clicked.
Expand All @@ -9,7 +37,11 @@ import useEventListener from './useEventListener';
*/
export function useBackdropClick(
/**
* Function called when any element other than those provided is clicked
* Function called when any element
* _other than_ those provided is clicked.
*
* Callback is fired on the `click` event's capture phase,
* (i.e. before a click handler on the target element is fired)
*/
callback: Function,

Expand All @@ -20,12 +52,11 @@ export function useBackdropClick(
| React.RefObject<HTMLElement>
| Array<React.RefObject<HTMLElement>>,

/**
* Whether the callback is enabled.
* It's recommended to set this to `false` when not in use,
* and toggle to `true` when the main elements (menu, tooltip, etc) are visible
*/
enabled = true,
/** Additional options for the hook. See {@link UseBackdropClickOptions} */
options: boolean | UseBackdropClickOptions = {
enabled: true,
allowPropagation: false,
},
): void {
/**
* We add two event handlers to the document to handle the backdrop click behavior.
Expand All @@ -42,12 +73,29 @@ export function useBackdropClick(
* 5. Then we call the callback (typically fires `closeMenu`, setting `isOpen = false`, and rerender the component)
*/

// TODO: Remove this in a major version
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make a ticket?

// https://jira.mongodb.org/browse/LG-5012
// To avoid a breaking change, we allow the `options` argument to be a boolean
// If it is a boolean, we assume that it is the `enabled` option
const { enabled, allowPropagation } =
typeof options === 'boolean'
? { enabled: options, allowPropagation: false }
: options;

if (typeof options === 'boolean') {
consoleOnce.warn(
"useBackdropClick: The 'enabled' boolean argument is deprecated. Please use the 'options' object argument instead.",
);
}

useEventListener(
'mousedown',
(mousedown: MouseEvent) => {
mousedown => {
if (!doesComponentContainEventTarget(mousedown)) {
mousedown.preventDefault(); // Prevent focus from being applied to body
mousedown.stopPropagation(); // Stop any other mousedown events from firing
if (!allowPropagation) {
mousedown.preventDefault(); // Prevent focus from being applied to body
mousedown.stopPropagation(); // Stop any other mousedown events from firing
}
}
},
{
Expand All @@ -57,10 +105,12 @@ export function useBackdropClick(

useEventListener(
'click',
(click: MouseEvent) => {
click => {
if (!doesComponentContainEventTarget(click)) {
click.stopPropagation(); // Stop any other click events from firing
callback();
if (!allowPropagation) {
click.stopPropagation(); // Stop any other click events from firing
}
callback(click);
}
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { getClosestFocusableElement } from './getClosestFocusableElement';

describe('getClosestFocusableElement', () => {
// Setup and cleanup
beforeEach(() => {
document.body.innerHTML = '';
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('when element itself is focusable', () => {
test('returns anchor element when element is an anchor', () => {
const anchor = document.createElement('a');
document.body.appendChild(anchor);
expect(getClosestFocusableElement(anchor)).toBe(anchor);
});

test('returns button element when element is a button', () => {
const button = document.createElement('button');
document.body.appendChild(button);
expect(getClosestFocusableElement(button)).toBe(button);
});

test('returns frame element when element is a frame', () => {
const frame = document.createElement('frame');
document.body.appendChild(frame);
expect(getClosestFocusableElement(frame)).toBe(frame);
});

test('returns iframe element when element is an iframe', () => {
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
expect(getClosestFocusableElement(iframe)).toBe(iframe);
});

test('returns input element when element is an input (not hidden)', () => {
const input = document.createElement('input');
document.body.appendChild(input);
expect(getClosestFocusableElement(input)).toBe(input);
});

test('does not return input when type is hidden', () => {
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
const div = document.createElement('div');
div.appendChild(hiddenInput);
document.body.appendChild(div);

expect(getClosestFocusableElement(hiddenInput)).toBe(document.body);
});

test('returns select element when element is a select', () => {
const select = document.createElement('select');
document.body.appendChild(select);
expect(getClosestFocusableElement(select)).toBe(select);
});

test('returns textarea element when element is a textarea', () => {
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
expect(getClosestFocusableElement(textarea)).toBe(textarea);
});

test('returns element with tabindex when element has tabindex', () => {
const div = document.createElement('div');
div.setAttribute('tabindex', '0');
document.body.appendChild(div);
expect(getClosestFocusableElement(div)).toBe(div);
});
});

describe('when element is not focusable but has focusable parent', () => {
test('returns the closest focusable parent', () => {
const button = document.createElement('button');
const span = document.createElement('span');
const text = document.createElement('p');

button.appendChild(span);
span.appendChild(text);
document.body.appendChild(button);

expect(getClosestFocusableElement(text)).toBe(button);
});

test('returns the closest focusable ancestor at any level', () => {
const anchor = document.createElement('a');
const div1 = document.createElement('div');
const div2 = document.createElement('div');
const div3 = document.createElement('div');

anchor.appendChild(div1);
div1.appendChild(div2);
div2.appendChild(div3);
document.body.appendChild(anchor);

expect(getClosestFocusableElement(div3)).toBe(anchor);
});
});

describe('when no focusable element is found', () => {
test('returns document.body when no focusable element exists in the chain', () => {
const div = document.createElement('div');
const span = document.createElement('span');
div.appendChild(span);
document.body.appendChild(div);

expect(getClosestFocusableElement(span)).toBe(document.body);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const focusableSelectors = [
'a',
'button',
'frame',
'iframe',
'input:not([type=hidden])',
'select',
'textarea',
'*[tabindex]',
] as const;

const focusableSelectorString = focusableSelectors.join(', ');

/**
* Crawls up the DOM tree (using `el.closest`) to find the closest focusable element.
* Returns the element itself if it is focusable.
* If no focusable element is found, it returns the body element.
*
* This is useful for ensuring that focus is returned to a valid element
* after an event, such as a click or key press.
*/
export const getClosestFocusableElement = (el: HTMLElement): HTMLElement => {
const focusableElement = el.closest(
focusableSelectorString,
) as HTMLElement | null;
return focusableElement ?? document.body;
};
1 change: 1 addition & 0 deletions packages/lib/src/getClosestFocusableElement/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { getClosestFocusableElement } from './getClosestFocusableElement';
1 change: 1 addition & 0 deletions packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import getTheme from './getTheme';
import { type LgIdProps } from './LgIdProps';
import * as typeIs from './typeIs';
export { createSyntheticEvent } from './createSyntheticEvent';
export { getClosestFocusableElement } from './getClosestFocusableElement';
export { getMobileMediaQuery } from './getMobileMediaQuery';
export * from './helpers';
export type {
Expand Down
33 changes: 31 additions & 2 deletions packages/menu/src/Menu.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import Button from '@leafygreen-ui/button';
import { Optional } from '@leafygreen-ui/lib';
import { RenderMode } from '@leafygreen-ui/popover';
import { waitForTransition } from '@leafygreen-ui/testing-lib';
Expand Down Expand Up @@ -173,8 +174,8 @@ describe('packages/menu', () => {
expect(menuEl).toBeInTheDocument();
});

test('Clicking outside the menu closes the menu', async () => {
const { openMenu, backdropEl } = renderMenu({});
test('Clicking outside the menu closes the menu, and keeps focus on the menu trigger', async () => {
const { openMenu, backdropEl, triggerEl } = renderMenu({});
const { menuEl } = await openMenu();

expect(menuEl).toBeInTheDocument();
Expand All @@ -183,6 +184,34 @@ describe('packages/menu', () => {
await waitForElementToBeRemoved(menuEl);

expect(menuEl).not.toBeInTheDocument();
expect(triggerEl).toHaveFocus();
});

test("Clicking a button outside the menu fires that button's handlers, and sets focus to the button", async () => {
const otherButtonHandler = jest.fn();
const { getByTestId, findByTestId } = render(
<>
<Menu trigger={defaultTrigger} data-testid={menuTestId}>
<MenuItem>Item A</MenuItem>
<MenuItem>Item B</MenuItem>
</Menu>
<Button data-testid="outside-button" onClick={otherButtonHandler}>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data-testid='outie-button'

Outside Button
</Button>
</>,
);
const button = getByTestId('outside-button');
const trigger = getByTestId(menuTriggerTestId);
userEvent.click(trigger);

const menuEl = await findByTestId(menuTestId);

userEvent.click(button);
await waitForElementToBeRemoved(menuEl);
await waitFor(() => {
expect(otherButtonHandler).toHaveBeenCalled();
expect(button).toHaveFocus();
});
});

test('Click handlers on parent elements fire (propagation is not stopped)', async () => {
Expand Down
Loading