Skip to content

Commit

Permalink
feat(dialog): improve focus logic
Browse files Browse the repository at this point in the history
- allow native autoFocus to be used
- don't implicitly focus first element with outline
- improve docs (add form sample)
  • Loading branch information
zettca committed May 24, 2023
1 parent f741d02 commit c52ced6
Show file tree
Hide file tree
Showing 2 changed files with 81 additions and 122 deletions.
117 changes: 68 additions & 49 deletions packages/core/src/components/Dialog/Dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
HvDialogProps,
HvDialogTitle,
HvDialogActions,
HvTypography,
HvInput,
HvTextArea,
HvGrid,
} from "@core/components";
import { useState } from "react";

Expand Down Expand Up @@ -39,7 +41,6 @@ const SimpleDialog = ({
</HvButton>
<HvDialog
disableBackdropClick
id="test"
classes={classes}
open={open}
onClose={() => setOpen(false)}
Expand All @@ -53,14 +54,10 @@ const SimpleDialog = ({
</HvDialogContent>
)}
<HvDialogActions>
<HvButton id="apply" variant="secondaryGhost">
<HvButton variant="secondaryGhost" onClick={() => setOpen(false)}>
Apply
</HvButton>
<HvButton
id="cancel"
variant="secondaryGhost"
onClick={() => setOpen(false)}
>
<HvButton variant="secondaryGhost" onClick={() => setOpen(false)}>
Cancel
</HvButton>
</HvDialogActions>
Expand Down Expand Up @@ -101,35 +98,21 @@ export const Main: StoryObj<HvDialogProps> = {

return (
<div>
<HvButton
id="openDialog"
style={{ width: "120px" }}
onClick={() => setOpen(true)}
>
<HvButton style={{ width: "120px" }} onClick={() => setOpen(true)}>
Open Dialog
</HvButton>
<HvDialog
id="test"
open={open}
{...args}
onClose={() => setOpen(false)}
firstFocusable="test-close"
>
<HvDialog open={open} {...args} onClose={() => setOpen(false)}>
<HvDialogTitle variant="warning">Switch model view?</HvDialogTitle>
<HvDialogContent indentContent>
Switching to model view will clear all the fields in your
visualization. You will need to re-select your fields.
</HvDialogContent>
<HvDialogActions>
<HvButton
id="apply"
variant="secondaryGhost"
onClick={() => setOpen(false)}
>
<HvButton variant="secondaryGhost" onClick={() => setOpen(false)}>
Apply
</HvButton>
<HvButton
id="cancel"
autoFocus
variant="secondaryGhost"
onClick={() => setOpen(false)}
>
Expand Down Expand Up @@ -188,23 +171,29 @@ export const IconAndSemantic: StoryObj<HvDialogProps> = {
},
};

export const Accessibility: StoryObj<HvDialogProps> = {
export const Form: StoryObj<HvDialogProps> = {
parameters: {
docs: {
description: {
story:
"Modals should have an `aria-labelledby` linking to the most appropriate element, as well as an optional `aria-describedby` pointing to the main content.",
"An example of using a `form` in `HvDialog`. The sample uses the `autofocus` attribute to focus the Title input by default.<br /> \
Accessibility-wise, `HvDialog` should have an `aria-labelledby` linking to the most appropriate element, \
as well as an optional `aria-describedby` pointing to the main content.",
},
},
},
render: () => {
const [open, setOpen] = useState(false);
const [postData, setPostData] = useState({});

return (
<div>
<>
<HvButton style={{ width: "120px" }} onClick={() => setOpen(true)}>
Open Dialog
Create a post
</HvButton>
<br />
<br />
Post data: {JSON.stringify(postData, null, 2)}
<HvDialog
disableBackdropClick
open={open}
Expand All @@ -213,49 +202,79 @@ export const Accessibility: StoryObj<HvDialogProps> = {
aria-describedby="hv-dialog-description"
>
<HvDialogTitle id="hv-dialog-title" variant="warning">
Switch model view?
Create a new post
</HvDialogTitle>
<HvDialogContent id="hv-dialog-description" indentContent>
Switching to model view will clear all the fields in your
visualization. You will need to re-select your fields.
<div id="hv-dialog-description" style={{ marginBottom: 10 }}>
Fill the following form to create a post.
</div>
<form
id="create-post"
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
setPostData(Object.fromEntries(formData.entries()));
setOpen(false);
}}
>
<HvGrid container>
<HvGrid item xs={12}>
<HvInput
required
name="author"
label="Author"
defaultValue="John Doe"
/>
</HvGrid>
<HvGrid item xs={12}>
<HvInput required name="title" label="Title" autoFocus />
</HvGrid>
<HvGrid item xs={12}>
<HvTextArea
required
label="Description"
name="content"
rows={4}
/>
</HvGrid>
</HvGrid>
</form>
</HvDialogContent>
<HvDialogActions>
<HvButton variant="secondaryGhost">Apply</HvButton>
<HvButton type="submit" form="create-post" variant="primary">
Create
</HvButton>
<HvButton variant="secondaryGhost" onClick={() => setOpen(false)}>
Cancel
</HvButton>
</HvDialogActions>
</HvDialog>
</div>
</>
);
},
};

export const LongContent: StoryObj<HvDialogProps> = {
parameters: {
docs: {
description: {
story:
"With very long content the dialog grows in height, up to a maximum where a margin of 100px is left on top and bottom.",
},
},
},
render: () => {
const [open, setOpen] = useState(false);

return (
<div>
<HvTypography>
With very long content the dialog should grow in height to a maximum
where a margin of 100px is left on top and bottom.
</HvTypography>
<br />
<br />
<HvButton
id="openDialog"
style={{ width: "120px" }}
onClick={() => setOpen(true)}
>
<HvButton style={{ width: "120px" }} onClick={() => setOpen(true)}>
Open dialog
</HvButton>
<HvDialog
disableBackdropClick
id="test"
open={open}
onClose={() => setOpen(false)}
firstFocusable="accept"
>
<HvDialogTitle variant="warning">Terms and Conditions</HvDialogTitle>
<HvDialogContent indentContent>
Expand Down Expand Up @@ -333,7 +352,7 @@ export const LongContent: StoryObj<HvDialogProps> = {
</HvDialogContent>
<HvDialogActions>
<HvButton
id="accept"
autoFocus
variant="secondaryGhost"
onClick={() => setOpen(false)}
>
Expand Down
86 changes: 13 additions & 73 deletions packages/core/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import React, { useCallback, useMemo, useRef } from "react";
import React, { useCallback, useMemo } from "react";
import { ClassNames } from "@emotion/react";
import MuiDialog, { DialogProps as MuiDialogProps } from "@mui/material/Dialog";
import { BackdropProps } from "@mui/material";
import isNil from "lodash/isNil";

import { Close } from "@hitachivantara/uikit-react-icons";
import { theme } from "@hitachivantara/uikit-styles";
import { HvBaseProps } from "@core/types/generic";
import {
isKeypress,
keyboardCodes,
setId,
getFocusableList,
} from "@core/utils";
import { setId } from "@core/utils";
import { withTooltip } from "@core/hocs";
import { useTheme } from "@core/hooks";
import dialogClasses, { HvDialogClasses } from "./dialogClasses";
Expand All @@ -34,7 +28,11 @@ export interface HvDialogProps
maxWidth?: MuiDialogProps["maxWidth"];
/** @inheritdoc */
fullWidth?: MuiDialogProps["fullWidth"];
/** Element id that should be focus when the Dialog opens. */
/**
* Element id that should be focus when the Dialog opens.
* Auto-focusing elements can cause usability issues, so this should be avoided.
* @deprecated Use `autoFocus` on the element instead, if auto-focusing is required.
*/
firstFocusable?: string;
/** Title for the button close. */
buttonTitle?: string;
Expand Down Expand Up @@ -75,11 +73,6 @@ export const HvDialog = ({

const { rootId } = useTheme();

const focusableQueue = useRef<{
first?: HTMLElement;
last?: HTMLElement;
}>({ first: undefined, last: undefined });

// Because the `disableBackdropClick` property was deprecated in MUI5
// and we want to maintain that functionality to the user we're wrapping
// the onClose call here to make that check.
Expand All @@ -89,71 +82,19 @@ export const HvDialog = ({
bypassValidation: boolean = false,
reason?: "escapeKeyDown" | "backdropClick"
) => {
if (bypassValidation) {
onClose?.(event, reason);
} else if (!disableBackdropClick) {
if (bypassValidation || !disableBackdropClick) {
onClose?.(event, reason);
}
},
[onClose]
);

const measuredRef = useCallback(
(node) => {
if (node) {
const focusableList = getFocusableList(node);
focusableQueue.current = {
first: focusableList[1],
last: focusableList[focusableList.length - 2],
};
if (isNil(firstFocusable)) focusableList[1].focus();
else {
const element =
firstFocusable && document.getElementById(firstFocusable);
if (element) element.focus();
else {
console.warn(`firstFocusable element ${firstFocusable} not found.`);

focusableList[1].focus();
}
}
}
},
[firstFocusable]
);

const keyDownHandler = (event) => {
if (
isKeypress(event, keyboardCodes.Tab) &&
!isNil(event.target) &&
!isNil(focusableQueue)
) {
if (event.shiftKey && event.target === focusableQueue.current.first) {
focusableQueue.current.last?.focus();
event.preventDefault();
}
if (!event.shiftKey && event.target === focusableQueue.current.last) {
focusableQueue.current.first?.focus();
event.preventDefault();
}
}
// Needed as this handler overrides the one in the material ui Modal.
else if (isKeypress(event, keyboardCodes.Esc)) {
if (
"onEscapeKeyDown" in others &&
typeof others.onEscapeKeyDown === "function"
) {
others.onEscapeKeyDown(event);
}

if (!others.disableEscapeKeyDown) {
// Swallow the event, in case someone is listening for the escape key on the body.
event.stopPropagation();
const measuredRef = useCallback(() => {
if (!firstFocusable) return;

wrappedClose(event, true, "escapeKeyDown");
}
}
};
const element = document.getElementById(firstFocusable);
element?.focus();
}, [firstFocusable]);

const closeButtonDisplay = () => <Close role="presentation" />;

Expand Down Expand Up @@ -181,7 +122,6 @@ export const HvDialog = ({
open={open}
fullScreen={fullscreen}
onClose={(event, reason) => wrappedClose(event, undefined, reason)}
onKeyDown={keyDownHandler}
slots={slots}
classes={{ container: css({ position: "relative" }) }}
BackdropProps={{
Expand Down

0 comments on commit c52ced6

Please sign in to comment.