Line data Source code
1 : import * as React from 'react'
2 : import { iterateFocusableElements } from '@primer/behaviors/utils'
3 : import { focusTrap, focusZone, FocusZoneSettings } from '@primer/behaviors';
4 :
5 : export type UseOpenAndCloseFocusArgs = {
6 : initialFocusRef?: React.RefObject<HTMLElement | null>
7 : containerRef: React.RefObject<HTMLElement | null>
8 : returnFocusRef?: React.RefObject<HTMLElement | null>
9 : preventFocusOnOpen?: boolean
10 : }
11 :
12 1 : export function useOpenAndCloseFocus(args: UseOpenAndCloseFocusArgs): void {
13 1 : React.useEffect(() => {
14 1 : if (args.preventFocusOnOpen) {
15 1 : return;
16 : }
17 1 : const returnRef = args.returnFocusRef?.current
18 1 : if (args.initialFocusRef && args.initialFocusRef.current) {
19 1 : args.initialFocusRef.current.focus()
20 1 : } else if (args.containerRef.current) {
21 1 : const firstItem = iterateFocusableElements(args.containerRef.current).next().value
22 1 : firstItem?.focus()
23 : }
24 1 : return function() {
25 1 : returnRef?.focus()
26 1 : }
27 1 : }, [args.initialFocusRef, args.returnFocusRef, args.containerRef, args.preventFocusOnOpen])
28 1 : }
29 :
30 :
31 : export interface FocusTrapHookArgs {
32 : /// Ref that will be used for the trapping container. If not provided, one will
33 : /// be created by this hook and returned.
34 : containerRef?: React.RefObject<HTMLElement>
35 : /// Ref for the element that should receive focus when the focus trap is first enabled. If
36 : /// not provided, one will be created by this hook and returned. Its use is optional.
37 : initialFocusRef?: React.RefObject<HTMLElement>
38 : /// Set to true to disable the focus trap and clean up listeners. Can be re-enabled at
39 : /// any time.
40 : disabled?: boolean
41 : /// If true, when this focus trap is cleaned up, restore focus to the element that had
42 : /// focus immediately before the focus trap was enabled. (Default: false)
43 : restoreFocusOnCleanUp?: boolean
44 : /// If passed, when this focus trap is cleaned up, restore focus to this element instead
45 : /// of element with focus immediately before the focus trap was enabled.
46 : returnFocusRef?: React.RefObject<HTMLElement>
47 : }
48 :
49 : /// Hook used to trap focus inside a container.
50 : /// Returns a ref that can be added to the container that should trap focus.
51 1 : export function useFocusTrap(
52 1 : args?: FocusTrapHookArgs,
53 1 : dependencies: React.DependencyList = [],
54 1 : ): { containerRef: React.RefObject<HTMLElement | null>; initialFocusRef: React.RefObject<HTMLElement | null> } {
55 1 : const altContainerRef = React.useRef<HTMLElement>(null);
56 1 : const altInitialFocusRef = React.useRef<HTMLElement>(null);
57 :
58 1 : const containerRef = args?.containerRef ?? altContainerRef;
59 1 : const initialFocusRef = args?.initialFocusRef ?? altInitialFocusRef;
60 1 : const disabled = args?.disabled;
61 1 : const abortController = React.useRef<AbortController | null>(null);
62 1 : const previousFocusedElement = React.useRef<Element | null>(null);
63 :
64 : // If we are enabling a focus trap and haven't already stored the previously focused element
65 : // go ahead an do that so we can restore later when the trap is disabled.
66 1 : if (!previousFocusedElement.current && !args?.disabled) {
67 1 : previousFocusedElement.current = document.activeElement
68 : }
69 : // This function removes the event listeners that enable the focus trap and restores focus
70 : // to the previously-focused element (if necessary).
71 1 : function disableTrap() {
72 1 : abortController.current?.abort()
73 1 : if (args?.returnFocusRef && args.returnFocusRef.current instanceof HTMLElement) {
74 1 : args.returnFocusRef.current.focus()
75 1 : } else if (args?.restoreFocusOnCleanUp && previousFocusedElement.current instanceof HTMLElement) {
76 1 : previousFocusedElement.current.focus()
77 1 : previousFocusedElement.current = null
78 1 : }
79 1 : }
80 :
81 1 : React.useEffect(
82 1 : () => {
83 1 : if (containerRef.current instanceof HTMLElement) {
84 1 : if (!disabled) {
85 1 : abortController.current = focusTrap(containerRef.current, initialFocusRef.current ?? undefined) ?? null;
86 1 : return () => {
87 1 : disableTrap()
88 1 : }
89 1 : } else {
90 1 : disableTrap()
91 : }
92 1 : }
93 1 : },
94 1 : [containerRef, initialFocusRef, disabled, ...dependencies],
95 1 : )
96 1 : return { containerRef, initialFocusRef };
97 1 : }
98 :
99 :
100 : export interface FocusZoneHookArgs extends Omit<FocusZoneSettings, 'activeDescendantControl'> {
101 : /// Optional ref for the container that holds all elements participating in arrow key focus.
102 : /// If one is not passed, we will create one for you and return it from the hook.
103 : containerRef?: React.RefObject<HTMLElement>
104 : /// If using the "active descendant" focus pattern, pass `true` or a ref to the controlling
105 : /// element. If a ref object is not passed, we will create one for you.
106 : activeDescendantFocus?: boolean | React.RefObject<HTMLElement>
107 : /// Set to true to disable the focus zone and clean up listeners. Can be re-enabled at any time.
108 : disabled?: boolean
109 : }
110 :
111 1 : export function useFocusZone(
112 1 : settings: FocusZoneHookArgs = {},
113 1 : dependencies: React.DependencyList = [],
114 1 : ): { containerRef: React.RefObject<HTMLElement | null>; activeDescendantControlRef: React.RefObject<HTMLElement | null> } {
115 1 : const altContainerRef = React.useRef<HTMLElement>(null);
116 1 : const altActiveDescFocusRef = React.useRef<HTMLElement>(null);
117 :
118 1 : const containerRef = settings.containerRef ?? altContainerRef;
119 1 : const useActiveDescendant = !!settings.activeDescendantFocus
120 : const passedActiveDescendantRef =
121 1 : typeof settings.activeDescendantFocus === 'boolean' || !settings.activeDescendantFocus
122 1 : ? undefined
123 1 : : settings.activeDescendantFocus
124 1 : const activeDescendantControlRef = passedActiveDescendantRef ?? altActiveDescFocusRef;
125 1 : const disabled = settings.disabled
126 1 : const abortController = React.useRef<AbortController | null>(null);
127 :
128 1 : React.useEffect(
129 1 : () => {
130 1 : if (
131 1 : containerRef.current instanceof HTMLElement &&
132 1 : (!useActiveDescendant || activeDescendantControlRef.current instanceof HTMLElement)
133 1 : ) {
134 1 : if (!disabled) {
135 1 : const vanillaSettings: FocusZoneSettings = {
136 1 : ...settings,
137 1 : activeDescendantControl: activeDescendantControlRef.current ?? undefined,
138 1 : }
139 1 : abortController.current = focusZone(containerRef.current, vanillaSettings)
140 1 : return () => {
141 1 : abortController.current?.abort()
142 1 : }
143 1 : } else {
144 1 : abortController.current?.abort()
145 : }
146 1 : }
147 1 : },
148 1 : [disabled, ...dependencies],
149 1 : )
150 1 : return { containerRef, activeDescendantControlRef }
151 1 : }
|