Skip to content

Commit

Permalink
fixup! fixup! Re-implement Modal component using HTMLDialogElement (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
bedrich-schindler committed Jan 14, 2025
1 parent bf704ca commit 61bcff4
Show file tree
Hide file tree
Showing 4 changed files with 91 additions and 97 deletions.
17 changes: 14 additions & 3 deletions src/components/Modal/Modal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const Modal = ({
dialogRef.current.showModal();
}, []);

useModalFocus(allowPrimaryActionOnEnterKey, autoFocus, dialogRef, primaryButtonRef);
useModalFocus(autoFocus, dialogRef, primaryButtonRef);
useModalScrollPrevention(preventScrollUnderneath);

const onCancel = useCallback(
Expand All @@ -76,8 +76,19 @@ export const Modal = ({
[closeButtonRef],
);
const onKeyDown = useCallback(
(e) => dialogOnKeyDownHandler(e, closeButtonRef, allowCloseOnEscapeKey),
[allowCloseOnEscapeKey, closeButtonRef],
(e) => dialogOnKeyDownHandler(
e,
closeButtonRef,
primaryButtonRef,
allowCloseOnEscapeKey,
allowPrimaryActionOnEnterKey,
),
[
allowCloseOnEscapeKey,
allowPrimaryActionOnEnterKey,
closeButtonRef,
primaryButtonRef,
],
);
const events = {
onCancel,
Expand Down
42 changes: 37 additions & 5 deletions src/components/Modal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,19 @@ See [API](#api) for all available options.
- **Modal actions** should correspond to the modal purpose, too. E.g. “Delete”
tells better what happens rather than “OK”.

- Modal **automatically focuses the first non-disabled form field** by default
which allows users to confirm the modal by hitting the enter key. When no
field is found then the primary button (in the footer) is focused. To turn
- Modal **automatically focuses the first non-disabled form field** by default.
When no field is found then the primary button (in the footer) is focused. To turn
this feature off, set the `autofocus` prop to `false`.

- Modal **submits the form when the user presses the `Enter` key** . The primary
button is clicked in this case. To turn this feature off, set the
`allowPrimaryActionOnEnterKey` prop to `false`.

- Modal **closes when the user presses the `Escape` key**. The close button is
clicked in this case. To turn this feature off, set the `allowCloseOnEscapeKey`
prop to `false`. Modal can be also **closed by clicking on the backdrop**. To
turn this feature off, set the `allowCloseOnBackdropClick` prop to `false`.

- **Avoid stacking** of modals. While it may technically work, the modal is just
not designed for that.

Expand Down Expand Up @@ -225,9 +233,33 @@ React.createElement(() => {
</ModalHeader>
<ModalBody>
<ModalContent>
<FormLayout fieldLayout="horizontal">
<TextField label="Username" />
<FormLayout fieldLayout="horizontal" labelWidth="limited">
<Toggle
label="Enabled"
/>
<TextField label="Username" required />
<TextField label="Password" type="password" />
<CheckboxField label="Force password on login" />
<Radio
label="Type of collaboration"
options={[
{ label: 'Internal', value: 'internal'},
{ label: 'External', value: 'external'},
]}
/>
<SelectField
label="Role"
options={[
{ label: 'Programmer', value: 'programmer' },
{ label: 'Team leader', value: 'team-leader' },
{ label: 'Project manager', value: 'project-manager' },
]}
/>
<FileInputField label="Photo" />
<TextArea
label="Additional info"
helpText={<p>Enter key is used for new line,<br />so <strong>Enter won't submit the form</strong>.</p>}
/>
</FormLayout>
</ModalContent>
</ModalBody>
Expand Down
29 changes: 22 additions & 7 deletions src/components/Modal/_helpers/dialogOnKeyDownHandler.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
export const dialogOnKeyDownHandler = (
e,
closeButtonRef,
primaryButtonRef,
allowCloseOnEscapeKey,
allowPrimaryActionOnEnterKey,
) => {
// When `allowCloseOnEscapeKey` is set to `false`, prevent closing the modal using the Escape key.
// Prevent closing the modal using the Escape key when one of the following conditions is met:
// 1. The close button is not present
// 2. The close button is disabled
// 3. `allowCloseOnEscapeKey` is set to `false`
if (
e.key === 'Escape'
&& !allowCloseOnEscapeKey
&& (
closeButtonRef?.current == null
|| closeButtonRef?.current?.disabled === true
|| !allowCloseOnEscapeKey
)
) {
e.preventDefault();
}

// When the close button is disabled, prevent closing the modal using the Escape key.
// Trigger the primary action when the Enter key is pressed and the following conditions are met:
// 1. The primary button is present
// 2. The primary button is not disabled
// 3. `allowPrimaryActionOnEnterKey` is set to `true`
// 4. The focused element is an input or select (text area is omitted as Enter key is used for new line)
if (
e.key === 'Escape'
&& closeButtonRef?.current != null
&& closeButtonRef?.current?.disabled === true
e.key === 'Enter'
&& primaryButtonRef?.current != null
&& primaryButtonRef?.current?.disabled === false
&& allowPrimaryActionOnEnterKey
&& ['INPUT', 'SELECT'].includes(e.target.nodeName)
) {
e.preventDefault();
primaryButtonRef.current.click();
}
};
100 changes: 18 additions & 82 deletions src/components/Modal/_hooks/useModalFocus.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useEffect } from 'react';

export const useModalFocus = (
allowPrimaryActionOnEnterKey,
autoFocus,
dialogRef,
primaryButtonRef,
Expand All @@ -26,95 +25,32 @@ export const useModalFocus = (
);

const firstFocusableElement = childrenFocusableElements[0];
const lastFocusableElement = childrenFocusableElements[childrenFocusableElements.length - 1];

const resolveFocusBeforeListener = () => {
if (!autoFocus || childrenFocusableElements.length === 0) {
dialogElement.tabIndex = -1;
dialogElement.focus();
return;
}

const firstFormFieldEl = childrenFocusableElements.find(
(element) => ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && !element.disabled,
);

if (firstFormFieldEl) {
firstFormFieldEl.focus();
return;
}

if (primaryButtonRef?.current != null && primaryButtonRef?.current?.disabled === false) {
primaryButtonRef.current.focus();
return;
}

firstFocusableElement.focus();
};

const keyPressHandler = (e) => {
if (
allowPrimaryActionOnEnterKey
&& e.key === 'Enter'
&& e.target.nodeName !== 'BUTTON'
&& e.target.nodeName !== 'TEXTAREA'
&& e.target.nodeName !== 'A'
&& primaryButtonRef?.current != null
&& primaryButtonRef?.current?.disabled === false
) {
primaryButtonRef.current.click();
return;
}

// Following code traps focus inside Modal

if (e.key !== 'Tab') {
return;
}

if (childrenFocusableElements.length === 0) {
dialogElement.focus();
e.preventDefault();
return;
}

if (
![
...childrenFocusableElements,
dialogElement,
]
.includes(window.document.activeElement)
) {
firstFocusableElement.focus();
e.preventDefault();
return;
}
if (!autoFocus || childrenFocusableElements.length === 0) {
dialogElement.tabIndex = -1;
dialogElement.focus();
return () => {};
}

if (!e.shiftKey && window.document.activeElement === lastFocusableElement) {
firstFocusableElement.focus();
e.preventDefault();
return;
}
const firstFormFieldEl = childrenFocusableElements.find(
(element) => ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && !element.disabled,
);

if (e.shiftKey
&& (
window.document.activeElement === firstFocusableElement
|| window.document.activeElement === dialogElement
)
) {
lastFocusableElement.focus();
e.preventDefault();
}
};
if (firstFormFieldEl) {
firstFormFieldEl.focus();
return () => {};
}

resolveFocusBeforeListener();
if (primaryButtonRef?.current != null && primaryButtonRef?.current?.disabled === false) {
primaryButtonRef.current.focus();
return () => {};
}

window.document.addEventListener('keydown', keyPressHandler, false);
firstFocusableElement.focus();

return () => window.document.removeEventListener('keydown', keyPressHandler, false);
return () => {};
},
[
allowPrimaryActionOnEnterKey,
autoFocus,
dialogRef,
primaryButtonRef,
Expand Down

0 comments on commit 61bcff4

Please sign in to comment.