Skip to content

Commit 0595b93

Browse files
Fafnorgcornut
authored andcommitted
chore(popover): allow to set the element to set a focus trap on
1 parent ee89e73 commit 0595b93

File tree

5 files changed

+23
-10
lines changed

5 files changed

+23
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
- DatePicker: add an input to change the displayed year
1818
- Message: add a `closeButtonProps` prop to add a close button in the message. Only available for `info` kind messages with a background.
19+
- Popover: add a `focusTrapZoneElement` prop to specify the element in which the focus trap should be applied.
1920

2021
### Changed
2122

packages/lumx-react/src/components/popover/Popover.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export interface PopoverProps extends GenericProps, HasTheme {
6060
placement?: Placement;
6161
/** Whether the popover should be rendered into a DOM node that exists outside the DOM hierarchy of the parent component. */
6262
usePortal?: boolean;
63+
/** The element in which the focus trap should be set. Default to popover. */
64+
focusTrapZoneElement?: RefObject<HTMLElement>;
6365
/** Z-axis position. */
6466
zIndex?: number;
6567
/** On close callback (on click away or Escape pressed). */
@@ -115,6 +117,7 @@ const _InnerPopover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, ref
115117
boundaryRef,
116118
fitToAnchorWidth,
117119
fitWithinViewportHeight,
120+
focusTrapZoneElement,
118121
offset,
119122
placement,
120123
style,
@@ -146,12 +149,13 @@ const _InnerPopover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, ref
146149
});
147150

148151
const unmountSentinel = useRestoreFocusOnClose({ focusAnchorOnClose, anchorRef, parentElement }, popperElement);
152+
const focusZoneElement = focusTrapZoneElement?.current || popoverRef?.current;
149153

150154
useCallbackOnEscape(onClose, isOpen && closeOnEscape);
151155

152156
/** Only set focus within if the focus trap is disabled as they interfere with one another. */
153157
useFocus(focusElement?.current, !withFocusTrap && isOpen && isPositioned);
154-
useFocusTrap(withFocusTrap && isOpen && popoverRef?.current, focusElement?.current);
158+
useFocusTrap(withFocusTrap && isOpen && focusZoneElement, focusElement?.current);
155159

156160
const clickAwayRefs = useRef([popoverRef, anchorRef]);
157161
const mergedRefs = useMergeRefs<HTMLDivElement>(setPopperElement, ref, popoverRef);

packages/lumx-react/src/hooks/useFocusTrap.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,27 +26,35 @@ export function useFocusTrap(focusZoneElement: HTMLElement | Falsy, focusElement
2626
return undefined;
2727
}
2828

29+
// Use the shadow root as focusZoneElement when available
30+
const focusZoneElementOrShadowRoot = focusZoneElement.shadowRoot || focusZoneElement;
31+
2932
// Trap 'Tab' key down focus switch into the focus zone.
3033
const trapTabFocusInFocusZone = (evt: KeyboardEvent) => {
3134
const { key } = evt;
3235
if (key !== 'Tab') {
3336
return;
3437
}
35-
const focusable = getFirstAndLastFocusable(focusZoneElement);
38+
39+
const focusable = getFirstAndLastFocusable(focusZoneElementOrShadowRoot);
3640

3741
// Prevent focus switch if no focusable available.
3842
if (!focusable.first) {
3943
evt.preventDefault();
4044
return;
4145
}
4246

47+
const activeElement = focusZoneElement.shadowRoot
48+
? focusZoneElement.shadowRoot.activeElement
49+
: document.activeElement;
50+
4351
if (
4452
// No previous focus
45-
!document.activeElement ||
53+
!activeElement ||
4654
// Previous focus is at the end of the focus zone.
47-
(!evt.shiftKey && document.activeElement === focusable.last) ||
55+
(!evt.shiftKey && activeElement === focusable.last) ||
4856
// Previous focus is outside the focus zone
49-
!focusZoneElement.contains(document.activeElement)
57+
!focusZoneElementOrShadowRoot.contains(activeElement)
5058
) {
5159
focusable.first.focus();
5260
evt.preventDefault();
@@ -57,7 +65,7 @@ export function useFocusTrap(focusZoneElement: HTMLElement | Falsy, focusElement
5765
// Focus order reversed
5866
evt.shiftKey &&
5967
// Previous focus is at the start of the focus zone.
60-
document.activeElement === focusable.first
68+
activeElement === focusable.first
6169
) {
6270
focusable.last.focus();
6371
evt.preventDefault();
@@ -70,12 +78,12 @@ export function useFocusTrap(focusZoneElement: HTMLElement | Falsy, focusElement
7078
};
7179

7280
// SETUP:
73-
if (focusElement && focusZoneElement.contains(focusElement)) {
81+
if (focusElement && focusZoneElementOrShadowRoot.contains(focusElement)) {
7482
// Focus the given element.
7583
focusElement.focus({ preventScroll: true });
7684
} else {
7785
// Focus the first focusable element in the zone.
78-
getFirstAndLastFocusable(focusZoneElement).first?.focus({ preventScroll: true });
86+
getFirstAndLastFocusable(focusZoneElementOrShadowRoot).first?.focus({ preventScroll: true });
7987
}
8088
FOCUS_TRAPS.register(focusTrap);
8189

packages/lumx-react/src/utils/focus/getFirstAndLastFocusable.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getFocusableElements } from './getFocusableElements';
66
* @param parentElement The element in which to search focusable elements.
77
* @return first and last focusable elements
88
*/
9-
export function getFirstAndLastFocusable(parentElement: HTMLElement) {
9+
export function getFirstAndLastFocusable(parentElement: HTMLElement | ShadowRoot) {
1010
const focusableElements = getFocusableElements(parentElement);
1111

1212
// First non disabled element.

packages/lumx-react/src/utils/focus/getFocusableElements.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ import { DISABLED_SELECTOR, TABBABLE_ELEMENTS_SELECTOR } from './constants';
22

33
const isNotDisabled = (element: HTMLElement) => !element.matches(DISABLED_SELECTOR);
44

5-
export function getFocusableElements(element: HTMLElement): HTMLElement[] {
5+
export function getFocusableElements(element: HTMLElement | ShadowRoot): HTMLElement[] {
66
return Array.from(element.querySelectorAll<HTMLElement>(TABBABLE_ELEMENTS_SELECTOR)).filter(isNotDisabled);
77
}

0 commit comments

Comments
 (0)