Skip to content

Commit a0916c7

Browse files
authored
fix: Fix Dialog initial focus (#433)
1 parent df639c6 commit a0916c7

File tree

6 files changed

+105
-29
lines changed

6 files changed

+105
-29
lines changed

.eslintrc.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ module.exports = {
7474
files: ["**/*.ts", "**/*.tsx"],
7575
parser: "@typescript-eslint/parser",
7676
parserOptions: {
77-
project: "./tsconfig.json"
77+
project: "./tsconfig.json",
78+
// TODO: Temporary fix https://github.com/typescript-eslint/typescript-eslint/issues/890
79+
createDefaultProgram: true
7880
},
7981
plugins: ["@typescript-eslint"],
8082
rules: {

packages/reakit-utils/src/tabbable.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,26 @@ export function focusPreviousTabbableIn<T extends Element>(
154154
previousTabbable.focus();
155155
}
156156
}
157+
158+
function defaultIsActive(element: Element) {
159+
return document.activeElement === element;
160+
}
161+
162+
type ForceFocusOptions = FocusOptions & {
163+
isActive?: typeof defaultIsActive;
164+
};
165+
166+
export function forceFocus(
167+
element: HTMLElement,
168+
{ isActive = defaultIsActive, preventScroll }: ForceFocusOptions = {}
169+
) {
170+
if (isActive(element)) return -1;
171+
172+
element.focus({ preventScroll });
173+
174+
if (isActive(element)) return -1;
175+
176+
return requestAnimationFrame(() => {
177+
element.focus({ preventScroll });
178+
});
179+
}

packages/reakit/src/Dialog/__tests__/index-test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,64 @@ test("focus a given element when dialog opens and initialFocusRef is passed in",
172172
expect(button2).toHaveFocus();
173173
});
174174

175+
test("focus a given element when dialog opens and initial focus has been manually set using React.useEffect", () => {
176+
const Test = () => {
177+
const dialog = useDialogState();
178+
const ref = React.useRef<HTMLButtonElement>(null);
179+
180+
React.useEffect(() => {
181+
if (dialog.visible && ref.current) {
182+
ref.current.focus();
183+
}
184+
}, [dialog.visible]);
185+
186+
return (
187+
<>
188+
<DialogDisclosure {...dialog}>disclosure</DialogDisclosure>
189+
<Dialog {...dialog} aria-label="dialog">
190+
<button>button1</button>
191+
<button ref={ref}>button2</button>
192+
</Dialog>
193+
</>
194+
);
195+
};
196+
const { getByText } = render(<Test />);
197+
const disclosure = getByText("disclosure");
198+
const button2 = getByText("button2");
199+
expect(document.body).toHaveFocus();
200+
fireEvent.click(disclosure);
201+
expect(button2).toHaveFocus();
202+
});
203+
204+
test("focus a given element when dialog opens and initial focus has been manually set using autoFocus", () => {
205+
const Test = () => {
206+
const dialog = useDialogState();
207+
return (
208+
<>
209+
<DialogDisclosure {...dialog}>disclosure</DialogDisclosure>
210+
<Dialog {...dialog} aria-label="dialog">
211+
{props =>
212+
dialog.visible && (
213+
<div {...props}>
214+
<button>button1</button>
215+
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
216+
<button autoFocus>button2</button>
217+
</div>
218+
)
219+
}
220+
</Dialog>
221+
</>
222+
);
223+
};
224+
const { getByText } = render(<Test />);
225+
const disclosure = getByText("disclosure");
226+
expect(document.body).toHaveFocus();
227+
expect(() => getByText("button2")).toThrow();
228+
fireEvent.click(disclosure);
229+
const button2 = getByText("button2");
230+
expect(button2).toHaveFocus();
231+
});
232+
175233
test("focus dialog itself if there is no tabbable descendant element", () => {
176234
const Test = () => {
177235
const dialog = useDialogState();

packages/reakit/src/Dialog/__utils/useDisableHoverOutside.ts

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,18 @@ export function useDisableHoverOutside(
77
nestedDialogs: Array<React.RefObject<HTMLElement>>,
88
options: DialogOptions
99
) {
10-
useEventListenerOutside(
11-
portalRef,
12-
{ current: null },
13-
nestedDialogs,
14-
"mouseover",
15-
event => {
16-
event.stopPropagation();
17-
event.preventDefault();
18-
},
19-
options.visible && options.modal
20-
);
21-
useEventListenerOutside(
22-
portalRef,
23-
{ current: null },
24-
nestedDialogs,
25-
"mouseout",
26-
event => {
27-
event.stopPropagation();
28-
event.preventDefault();
29-
},
30-
options.visible && options.modal
31-
);
10+
const useEvent = (eventType: string) =>
11+
useEventListenerOutside(
12+
portalRef,
13+
{ current: null },
14+
nestedDialogs,
15+
eventType,
16+
event => {
17+
event.stopPropagation();
18+
event.preventDefault();
19+
},
20+
options.visible && options.modal
21+
);
22+
useEvent("mouseover");
23+
useEvent("mouseout");
3224
}

packages/reakit/src/Dialog/__utils/useFocusOnHide.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from "react";
22
import { useUpdateEffect } from "reakit-utils/useUpdateEffect";
33
import { warning } from "reakit-utils/warning";
4-
import { isTabbable } from "reakit-utils/tabbable";
4+
import { isTabbable, forceFocus } from "reakit-utils/tabbable";
55
import { DialogOptions } from "../Dialog";
66

77
export function useFocusOnHide(
@@ -33,7 +33,7 @@ export function useFocusOnHide(
3333
(disclosureRefs.current && disclosureRefs.current[0]);
3434

3535
if (finalFocusRef) {
36-
finalFocusRef.focus();
36+
forceFocus(finalFocusRef);
3737
} else {
3838
warning(
3939
true,

packages/reakit/src/Dialog/__utils/useFocusOnShow.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from "react";
22
import { useUpdateEffect } from "reakit-utils/useUpdateEffect";
33
import { warning } from "reakit-utils/warning";
4-
import { getFirstTabbableIn } from "reakit-utils/tabbable";
4+
import { getFirstTabbableIn, forceFocus } from "reakit-utils/tabbable";
55
import { DialogOptions } from "../Dialog";
66

77
export function useFocusOnShow(
@@ -37,16 +37,17 @@ export function useFocusOnShow(
3737
initialFocusRef.current.focus({ preventScroll: true });
3838
} else {
3939
const tabbable = getFirstTabbableIn(dialog, true);
40+
const isActive = () => dialog.contains(document.activeElement);
4041
if (tabbable) {
41-
tabbable.focus({ preventScroll: true });
42+
forceFocus(tabbable, { preventScroll: true, isActive });
4243
} else {
43-
dialog.focus({ preventScroll: true });
44+
forceFocus(dialog, { preventScroll: true, isActive });
4445
warning(
4546
dialog.tabIndex === undefined || dialog.tabIndex < 0,
4647
"Dialog",
4748
"It's recommended to have at least one tabbable element inside dialog. The dialog element has been automatically focused.",
4849
"If this is the intended behavior, pass `tabIndex={0}` to the dialog element to disable this warning.",
49-
"See https://reakit.io/docs/dialog"
50+
"See https://reakit.io/docs/dialog/#initial-focus"
5051
);
5152
}
5253
}

0 commit comments

Comments
 (0)