-
Notifications
You must be signed in to change notification settings - Fork 944
Fleet UI: Keyboard focus enters, traps in, and restores from modals #49437
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5cb1953
c19eae1
8796a0c
4a99790
e6c3a81
c133c29
6d8876c
88f1314
56c1d74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| - Improved keyboard accessibility for modals: keyboard focus enters the modal on open and returns to the previously focused control when the modal closes. | ||
| - Fixed a bug where holding Enter on the "Manage automations" button could auto-submit the form before the modal opened. | ||
| - Fixed a bug where pressing Escape closed both modals when one was stacked on top of another. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,10 @@ | ||
| import React, { useCallback, useEffect, useRef, useState } from "react"; | ||
| import React, { | ||
| useCallback, | ||
| useEffect, | ||
| useLayoutEffect, | ||
| useRef, | ||
| useState, | ||
| } from "react"; | ||
| import classnames from "classnames"; | ||
| import Button from "components/buttons/Button/Button"; | ||
| import Icon from "components/Icon/Icon"; | ||
|
|
@@ -56,11 +62,45 @@ const Modal = ({ | |
| disableClosingModal = false, | ||
| className, | ||
| }: IModalProps): JSX.Element => { | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const isDownOnBackgroundRef = useRef(false); | ||
| const isFormDirtyRef = useRef(false); | ||
| const [isClosing, setIsClosing] = useState(false); | ||
| const isClosingRef = useRef(false); | ||
|
|
||
| // Latest-value ref for document-level handlers to consult isHidden without | ||
| // re-running their mount effects. isHidden signals a sibling modal is | ||
| // stacked on top of this one; that modal owns focus and keyboard input. | ||
| const isHiddenRef = useRef(isHidden); | ||
| useLayoutEffect(() => { | ||
| isHiddenRef.current = isHidden; | ||
| }, [isHidden]); | ||
|
|
||
| useEffect(() => { | ||
| const container = containerRef.current; | ||
| if (!container) return undefined; | ||
|
|
||
| const previouslyFocused = document.activeElement as HTMLElement | null; | ||
| // Skip if a stacked modal is on top or if a child already claimed focus | ||
| // (e.g. an autoFocused InputField). Restore on unmount only when we took | ||
| // focus and are not hidden at close time, so a stacked-then-unmount flow | ||
| // doesn't yank focus away from the top modal. | ||
| const tookFocus = | ||
| !isHiddenRef.current && !container.contains(document.activeElement); | ||
| if (tookFocus) container.focus(); | ||
|
|
||
| return () => { | ||
| if ( | ||
| tookFocus && | ||
| !isHiddenRef.current && | ||
| previouslyFocused && | ||
| document.body.contains(previouslyFocused) | ||
| ) { | ||
| previouslyFocused.focus(); | ||
| } | ||
| }; | ||
| }, []); | ||
|
|
||
| const handleClose = useCallback(() => { | ||
| if (isClosingRef.current) return; | ||
| isClosingRef.current = true; | ||
|
|
@@ -72,6 +112,7 @@ const Modal = ({ | |
|
|
||
| useEffect(() => { | ||
| const closeWithEscapeKey = (e: KeyboardEvent) => { | ||
| if (isHiddenRef.current) return; | ||
| if (e.key === "Escape") { | ||
| handleClose(); | ||
| } | ||
|
|
@@ -91,6 +132,11 @@ const Modal = ({ | |
| useEffect(() => { | ||
| if (onEnter) { | ||
| const closeOrSaveWithEnterKey = (event: KeyboardEvent) => { | ||
| // Ignore Enter while a stacked modal is on top of this one. | ||
| if (isHiddenRef.current) return; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like this was added here but not to the one.esc.closes.both.stacked.modals.mov |
||
| // Skip autorepeated keys: the trigger button's Enter may still be | ||
| // held when the modal mounts and this listener attaches. | ||
| if (event.repeat) return; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This prevents a bug I saw where if I used enter to open a modal that has enter to submit, it would submit and close the modal automagically on open >.< |
||
| if (event.code === "Enter" || event.code === "NumpadEnter") { | ||
| event.preventDefault(); | ||
| onEnter(); | ||
|
|
@@ -194,8 +240,9 @@ const Modal = ({ | |
| onMouseUp={handleBackgroundMouseUp} | ||
| > | ||
| <div | ||
| ref={containerRef} | ||
| className={modalContainerClasses} | ||
| tabIndex={-1} // Make focusable | ||
| tabIndex={-1} | ||
| onMouseDown={handleContainerMouseDown} | ||
| onMouseUp={handleContainerMouseUp} | ||
| onInput={handleContainerInput} | ||
|
|
@@ -205,12 +252,7 @@ const Modal = ({ | |
| <span>{title}</span> | ||
| {!disableClosingModal && ( | ||
| <div className={`${baseClass}__ex`}> | ||
| <Button | ||
| variant="icon" | ||
| onClick={handleClose} | ||
| iconStroke | ||
| autofocus={isContentDisabled} | ||
| > | ||
| <Button variant="icon" onClick={handleClose} iconStroke> | ||
| <Icon name="close" color="core-fleet-black" size="medium" /> | ||
| </Button> | ||
| </div> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought this might steal focus from an autofocused input field, and it looks like it does:
Modal.steals.focus.from.autofocused.input.mov
We might skip this if the container already has a
document.activeElement, or focus on the first focusable child element.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oofph! Will look into this more!!!