Skip to content

Commit e68add7

Browse files
committed
feat: add confirmation dialog for destructive actions #4743
1 parent 8fe7a97 commit e68add7

File tree

3 files changed

+394
-14
lines changed

3 files changed

+394
-14
lines changed

frontend/pages/sites/$siteId/storage/index.js

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useRef } from 'react';
1+
import React, { useState, useRef, useCallback } from 'react';
22
import { useParams, useSearchParams } from 'react-router-dom';
33
import { useSelector } from 'react-redux';
44
import useFileStorage from '@hooks/useFileStorage';
@@ -10,7 +10,7 @@ import NewFileOrFolder from './NewFileOrFolder';
1010
import FileList from './FileList';
1111
import Pagination from '@shared/Pagination';
1212
import QueryPage from '@shared/layouts/QueryPage';
13-
13+
import Dialog from '@shared/Dialog';
1414
import { currentSite } from '@selectors/site';
1515

1616
function FileStoragePage() {
@@ -80,6 +80,17 @@ function FileStoragePage() {
8080
scrollToTop();
8181
};
8282

83+
const INITIAL_DIALOG_PROPS = {
84+
open: false,
85+
};
86+
const resetModal = useCallback(() => {
87+
setDialogProps(INITIAL_DIALOG_PROPS);
88+
}, []);
89+
90+
const [dialogProps, setDialogProps] = useState({
91+
closeHandler: resetModal,
92+
});
93+
8394
const handlePageChange = (newPage) => {
8495
if (newPage === currentPage) return;
8596
setSearchParams((prev) => {
@@ -135,18 +146,32 @@ function FileStoragePage() {
135146
});
136147
};
137148

138-
const handleDelete = async (item) => {
139-
const isFolder = item.type === 'directory';
140-
const confirmMessage = isFolder
141-
? // eslint-disable-next-line sonarjs/slow-regex
142-
`Are you sure you want to delete the folder "${item.name.replace(/\/+$/, '')}"?
149+
const handleDelete = useCallback(
150+
async (item) => {
151+
const isFolder = item.type === 'directory';
152+
const confirmMessage = isFolder
153+
? // eslint-disable-next-line sonarjs/slow-regex
154+
`Are you sure you want to delete the folder "${item.name.replace(/\/+$/, '')}"?
143155
Please check that it does not contain any files.`
144-
: `Are you sure you want to delete the file "${item.name}"?`;
145-
146-
if (!window.confirm(confirmMessage)) return;
147-
148-
await deleteItem(item);
149-
};
156+
: `Are you sure you want to delete the file "${item.name}"?`;
157+
const deleteHandler = async () => {
158+
await deleteItem(item);
159+
resetModal();
160+
};
161+
setDialogProps({
162+
...dialogProps,
163+
open: true,
164+
header: 'Are you sure?',
165+
message: confirmMessage,
166+
primaryButton: 'Yes, I want to delete',
167+
primaryHandler: deleteHandler,
168+
secondaryButton: 'Cancel',
169+
secondaryHandler: resetModal,
170+
closeHandler: resetModal,
171+
});
172+
},
173+
[deleteItem, resetModal],
174+
);
150175

151176
const handleUpload = async (files) => {
152177
await Promise.all(files.map((file) => uploadFile(path, file)));
@@ -155,6 +180,7 @@ function FileStoragePage() {
155180
const handleCreateFolder = async (folderName) => {
156181
await createFolder(path, folderName);
157182
};
183+
158184
return (
159185
<QueryPage
160186
data={fetchedPublicFiles}
@@ -203,7 +229,7 @@ function FileStoragePage() {
203229
message={createFolderSuccess}
204230
/>
205231
)}
206-
232+
<Dialog {...dialogProps} />
207233
<div className="grid-col-12" ref={scrollTo}>
208234
<LocationBar
209235
path={path}

frontend/shared/Dialog.jsx

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import PropTypes from 'prop-types';
3+
4+
const Dialog = ({
5+
header = 'Are you sure you want to continue?',
6+
message = 'You have unsaved changes that will be lost.',
7+
primaryButton = 'Continue without saving',
8+
secondaryButton = null,
9+
primaryHandler = () => {},
10+
secondaryHandler = () => {},
11+
closeHandler = () => {},
12+
children = null,
13+
dismissable = true,
14+
open = false,
15+
}) => {
16+
const dialogRef = useRef(null);
17+
const firstFocusableRef = useRef(null);
18+
const lastFocusedElementRef = useRef(null);
19+
// focus trap for better a11y
20+
useEffect(() => {
21+
if (open) {
22+
lastFocusedElementRef.current = document.activeElement;
23+
dialogRef.current?.focus();
24+
25+
const focusableElements = dialogRef.current?.querySelectorAll(
26+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
27+
);
28+
29+
if (focusableElements.length > 0) {
30+
firstFocusableRef.current = focusableElements[0];
31+
firstFocusableRef.current.focus();
32+
}
33+
} else {
34+
setTimeout(() => {
35+
lastFocusedElementRef.current?.focus();
36+
}, 10);
37+
}
38+
}, [open]);
39+
40+
// eslint-disable-next-line sonarjs/cognitive-complexity
41+
const handleKeyDown = (e) => {
42+
if (e.key === 'Escape' && dismissable) {
43+
closeHandler();
44+
}
45+
46+
if (e.key === 'Tab') {
47+
e.preventDefault(); // Prevent native tab behavior
48+
49+
const focusableElements = dialogRef.current?.querySelectorAll(
50+
// eslint-disable-next-line max-len
51+
'button, [href]:not(use), input, select, textarea, [tabindex]:not([tabindex="-1"])',
52+
);
53+
54+
// Convert to array & ignore the SVG icon from USWDS that also has href
55+
const focusableArray = Array.from(focusableElements).filter(
56+
(el) => el.tagName !== 'USE', // some browsers are weird about svgs
57+
);
58+
59+
if (focusableArray.length === 0) return;
60+
61+
const firstElement = focusableArray[0];
62+
const lastElement = focusableArray[focusableArray.length - 1];
63+
64+
// Shift+Tab moves backward
65+
if (e.shiftKey && document.activeElement === firstElement) {
66+
lastElement.focus();
67+
return;
68+
}
69+
70+
// Tab moves forward
71+
if (!e.shiftKey && document.activeElement === lastElement) {
72+
firstElement.focus();
73+
return;
74+
}
75+
76+
// Find the currently focused element and move focus
77+
for (let i = 0; i < focusableArray.length; i += 1) {
78+
if (focusableArray[i] === document.activeElement) {
79+
const nextIndex = i + (e.shiftKey ? -1 : 1);
80+
focusableArray[nextIndex]?.focus();
81+
break; // Prevent further looping
82+
}
83+
}
84+
}
85+
};
86+
87+
if (!open) return;
88+
return (
89+
<>
90+
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
91+
<div
92+
className={`usa-modal-wrapper ${open ? 'is-visible' : ''}`}
93+
ref={dialogRef}
94+
role="dialog"
95+
aria-labelledby="modal-heading"
96+
aria-describedby="modal-description"
97+
onKeyDown={handleKeyDown}
98+
tabIndex={-1} // Allow focus
99+
>
100+
{' '}
101+
{/* eslint-disable-next-line max-len */}
102+
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
103+
<div
104+
data-testid="modal-overlay"
105+
className="usa-modal-overlay"
106+
onClick={(e) => {
107+
if (dismissable && e.target === e.currentTarget) {
108+
closeHandler();
109+
}
110+
}}
111+
></div>
112+
<div className="usa-modal">
113+
<div className="usa-modal__content">
114+
<div className="usa-modal__main">
115+
<h2 className="usa-modal__heading" id="modal-heading">
116+
{header}
117+
</h2>
118+
{message && (
119+
<div className="usa-prose">
120+
<p id="modal-description">{message}</p>
121+
</div>
122+
)}
123+
{children}
124+
<div className="usa-modal__footer">
125+
<ul className="usa-button-group">
126+
{primaryButton && (
127+
<li className="usa-button-group__item">
128+
<button
129+
tabIndex="0"
130+
type="button"
131+
className="usa-button"
132+
data-close-modal
133+
onClick={primaryHandler}
134+
>
135+
{primaryButton}
136+
</button>
137+
</li>
138+
)}
139+
{secondaryButton && (
140+
<li className="usa-button-group__item">
141+
<button
142+
tabIndex="0"
143+
type="button"
144+
className="usa-button usa-button--unstyled padding-105"
145+
data-close-modal
146+
onClick={secondaryHandler}
147+
>
148+
{secondaryButton}
149+
</button>
150+
</li>
151+
)}
152+
</ul>
153+
</div>
154+
</div>
155+
{dismissable && (
156+
<button
157+
tabIndex="0"
158+
type="button"
159+
className="usa-button usa-modal__close"
160+
aria-label="Close this window"
161+
data-close-modal
162+
onClick={(e) => {
163+
e.stopPropagation();
164+
closeHandler();
165+
}}
166+
>
167+
<svg className="usa-icon" aria-hidden="true" focusable="false" role="img">
168+
<use href="/img/sprite.svg#close"></use>
169+
</svg>
170+
</button>
171+
)}
172+
</div>
173+
</div>
174+
</div>
175+
</>
176+
);
177+
};
178+
179+
Dialog.propTypes = {
180+
header: PropTypes.string,
181+
message: PropTypes.string,
182+
primaryButton: PropTypes.string,
183+
secondaryButton: PropTypes.string,
184+
primaryHandler: PropTypes.func,
185+
secondaryHandler: PropTypes.func,
186+
closeHandler: PropTypes.func,
187+
children: PropTypes.node,
188+
dismissable: PropTypes.bool,
189+
open: PropTypes.bool,
190+
};
191+
192+
export default Dialog;

0 commit comments

Comments
 (0)