Skip to content

Commit b710105

Browse files
authored
Remove showModal to replace it by css to not block windows bar actions (#441)
1 parent 2630b3d commit b710105

File tree

3 files changed

+183
-29
lines changed

3 files changed

+183
-29
lines changed

src/views/components/editor/EditorOverlayV2.tsx

Lines changed: 164 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ import React, { forwardRef, PropsWithoutRef, ReactNode, useEffect, useImperative
44
import { createPortal } from 'react-dom';
55
import styled from 'styled-components';
66

7+
export const BackDrop = styled.div`
8+
display: none; /* Hidden by default */
9+
position: fixed;
10+
z-index: 99; /* Ensure the backdrop is just below the modal */
11+
left: 0;
12+
width: 100vw; /* Full viewport width */
13+
14+
background-color: rgba(10, 9, 11, 0.3);
15+
top: ${({ theme }) => theme.calc.titleBarHeight};
16+
height: ${({ theme }) => theme.calc.height};
17+
18+
&.open {
19+
display: block; /* Show the backdrop when open */
20+
}
21+
`;
22+
723
export const DialogContainer = styled.dialog`
824
overflow: visible;
925
padding: 0;
@@ -16,38 +32,41 @@ export const DialogContainer = styled.dialog`
1632
top: ${({ theme }) => theme.calc.titleBarHeight};
1733
height: ${({ theme }) => theme.calc.height};
1834
left: 100%;
35+
36+
&.open {
37+
transform: translateX(-100%);
38+
opacity: 1;
39+
}
40+
}
41+
42+
&.open {
43+
display: block;
44+
z-index: 100;
1945
}
2046
2147
&.center {
2248
opacity: 0;
49+
50+
&.open {
51+
opacity: 1;
52+
top: 40%;
53+
}
2354
}
2455
2556
& > div {
2657
left: unset;
2758
position: unset;
2859
}
2960
30-
&::backdrop {
31-
background-color: rgba(10, 9, 11, 0.3);
32-
top: ${({ theme }) => theme.calc.titleBarHeight};
33-
height: ${({ theme }) => theme.calc.height};
34-
}
35-
36-
&.right[open] {
37-
transform: translateX(-100%);
38-
}
39-
40-
&.center[open] {
41-
opacity: 1;
42-
}
43-
4461
&:focus {
4562
outline: 0;
4663
}
4764
`;
4865

4966
const onDialogCancel = (e: Event) => e.preventDefault();
5067

68+
const focusableHtmlElements = 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])';
69+
5170
const animationKeys = {
5271
right: {
5372
open: [
@@ -80,19 +99,39 @@ const animationOption = {
8099
easing: 'ease-in',
81100
} as const;
82101

83-
const closeDialogWithAnimation = (dialog: HTMLDialogElement, isCenter: boolean, onFinish: () => void) => {
102+
const closeDialogWithAnimation = (dialog: HTMLDialogElement, backdrop: HTMLDivElement, isCenter: boolean, onFinish: () => void) => {
84103
const animation = dialog.animate(isCenter ? animationKeys.center.close : animationKeys.right.close, animationOption);
104+
85105
animation.onfinish = () => {
86-
onFinish();
106+
dialog.classList.remove('open');
107+
backdrop.classList.remove('open');
87108
dialog.close();
109+
onFinish();
88110
dialog.removeEventListener('cancel', onDialogCancel);
111+
112+
// If another dialogs is open, then focus the first focusable element in this modal
113+
const otherDialogs = document.querySelectorAll('dialog.open');
114+
if (otherDialogs.length) {
115+
const focusableElements = otherDialogs[otherDialogs.length - 1].querySelectorAll(focusableHtmlElements);
116+
if (focusableElements.length > 0) {
117+
(focusableElements[0] as HTMLElement).focus();
118+
}
119+
}
89120
};
90121
};
91122

92-
const openDialogWithAnimation = (dialog: HTMLDialogElement, isCenter: boolean) => {
123+
const openDialogWithAnimation = (dialog: HTMLDialogElement, backdrop: HTMLDivElement, isCenter: boolean) => {
93124
dialog.addEventListener('cancel', onDialogCancel);
94-
dialog.showModal();
125+
dialog.classList.add('open');
126+
backdrop.classList.add('open');
95127
dialog.animate(isCenter ? animationKeys.center.open : animationKeys.right.open, animationOption);
128+
129+
setTimeout(() => {
130+
const focusableElements = dialog.querySelectorAll(focusableHtmlElements);
131+
if (focusableElements?.length) {
132+
(focusableElements[0] as HTMLElement).focus();
133+
}
134+
}, animationOption.duration);
96135
};
97136

98137
/**
@@ -138,10 +177,12 @@ export const defineEditorOverlay = <Keys extends string, Props extends Record<st
138177
const handleCloseRef = useEditorHandlingCloseRef();
139178
const [currentDialog, setCurrentDialog] = useState<Keys | undefined>(undefined);
140179
const [isCenter, setIsCenter] = useState(false);
180+
const backdropRef = useRef<HTMLDivElement>(null);
141181

142182
const closeDialog = () => {
143183
handleCloseRef.current?.onClose();
144-
if (dialogRef.current) closeDialogWithAnimation(dialogRef.current, isCenter, () => setCurrentDialog(undefined));
184+
if (dialogRef.current && backdropRef.current)
185+
closeDialogWithAnimation(dialogRef.current, backdropRef.current, isCenter, () => setCurrentDialog(undefined));
145186
};
146187

147188
const currentlyRenderedDialog = useMemo(() => {
@@ -154,14 +195,17 @@ export const defineEditorOverlay = <Keys extends string, Props extends Record<st
154195
if (handleCloseRef.current?.canClose()) closeDialog();
155196
};
156197

157-
const onClickOutside = (e: React.MouseEvent<HTMLDialogElement, MouseEvent>) => {
198+
const onClickOutside = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
158199
if (e.currentTarget === e.target) onEscape();
159200
};
160201

161202
const openDialog = (name: Keys, isCenterDialog?: boolean) => {
162203
setIsCenter(isCenterDialog || false);
163204
setCurrentDialog(name);
164-
if (dialogRef.current) openDialogWithAnimation(dialogRef.current, isCenterDialog || false);
205+
// Without tick, if the dialog change between center and right, the dialog is not displayed correctly
206+
setTimeout(() => {
207+
if (dialogRef.current && backdropRef.current) openDialogWithAnimation(dialogRef.current, backdropRef.current, isCenterDialog || false);
208+
}, 0);
165209
};
166210

167211
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -181,12 +225,105 @@ export const defineEditorOverlay = <Keys extends string, Props extends Record<st
181225
// eslint-disable-next-line react-hooks/exhaustive-deps
182226
}, [currentDialog]);
183227

184-
return createPortal(
185-
<DialogContainer ref={dialogRef} onMouseDown={onClickOutside} className={isCenter ? 'center' : 'right'}>
186-
{currentlyRenderedDialog}
187-
</DialogContainer>,
188-
document.querySelector('#dialogs') || document.createElement('div')
189-
);
228+
useEffect(() => {
229+
const handleTabKey = (event: KeyboardEvent) => {
230+
if (event.key !== 'Tab') return;
231+
232+
const openDialogs = document.querySelectorAll('dialog.open');
233+
if (openDialogs.length === 0) return;
234+
235+
const lastDialog = openDialogs[openDialogs.length - 1] as HTMLDialogElement;
236+
if (!lastDialog.contains(event.target as Node)) return;
237+
238+
const focusableElements = Array.from(lastDialog.querySelectorAll(focusableHtmlElements)) as HTMLElement[];
239+
if (focusableElements.length === 0) return;
240+
241+
const firstElement = focusableElements[0];
242+
const lastElement = focusableElements[focusableElements.length - 1];
243+
244+
if (event.shiftKey) {
245+
246+
if (document.activeElement === firstElement) {
247+
event.preventDefault();
248+
if (focusableElements[focusableElements.length - 2].getAttribute('type') === 'hidden') {
249+
const lastFocusableElement = focusableElements
250+
.reverse()
251+
.find((el) => el.getAttribute('type') !== 'hidden' && el.getAttribute('aria-hidden') !== 'true');
252+
lastFocusableElement?.focus();
253+
} else {
254+
focusableElements[focusableElements.length - 2].focus();
255+
}
256+
}
257+
} else {
258+
if (document.activeElement === lastElement) {
259+
event.preventDefault();
260+
focusableElements[1].focus();
261+
}
262+
}
263+
};
264+
265+
const handleCtrlA = (event: KeyboardEvent) => {
266+
if (event.key === 'a' && (event.ctrlKey || event.metaKey)) {
267+
const openDialogs = document.querySelectorAll('dialog.open');
268+
if (openDialogs.length === 0) return;
269+
270+
const lastDialog = openDialogs[openDialogs.length - 1] as HTMLDialogElement;
271+
if (lastDialog.contains(event.target as Node)) {
272+
event.preventDefault();
273+
const target = event.target as HTMLElement;
274+
275+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
276+
(target as HTMLInputElement | HTMLTextAreaElement).select();
277+
} else {
278+
const range = document.createRange();
279+
range.selectNodeContents(lastDialog);
280+
const selection = window.getSelection();
281+
selection?.removeAllRanges();
282+
selection?.addRange(range);
283+
}
284+
}
285+
}
286+
};
287+
288+
window.addEventListener('keydown', handleCtrlA);
289+
window.addEventListener('keydown', handleTabKey);
290+
return () => {
291+
window.removeEventListener('keydown', handleCtrlA);
292+
window.removeEventListener('keydown', handleTabKey);
293+
};
294+
}, [currentDialog]);
295+
296+
return createPortal(
297+
<>
298+
<BackDrop ref={backdropRef} onClick={onClickOutside}></BackDrop>
299+
<DialogContainer ref={dialogRef} className={isCenter ? 'center' : 'right'}>
300+
<div
301+
tabIndex={0}
302+
aria-hidden="true"
303+
onFocus={(e) => {
304+
const focusableElements = Array.from(dialogRef.current?.querySelectorAll(focusableHtmlElements) || []) as HTMLElement[];
305+
if (focusableElements.length > 0 && e.relatedTarget === focusableElements[focusableElements.length - 1]) {
306+
requestAnimationFrame(() => focusableElements[0].focus());
307+
}
308+
}}
309+
></div>
310+
311+
{currentlyRenderedDialog}
312+
313+
<div
314+
tabIndex={0}
315+
aria-hidden="true"
316+
onFocus={(e) => {
317+
const focusableElements = Array.from(dialogRef.current?.querySelectorAll(focusableHtmlElements) || []) as HTMLElement[];
318+
if (focusableElements.length > 0 && e.relatedTarget === focusableElements[0]) {
319+
requestAnimationFrame(() => focusableElements[focusableElements.length - 1].focus());
320+
}
321+
}}
322+
></div>
323+
</DialogContainer>
324+
</>,
325+
document.querySelector('#dialogs') || document.createElement('div')
326+
);
190327
});
191328
reactComponent.displayName = displayName;
192329
return reactComponent;

src/views/components/inputs/Input.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import styled, { css } from 'styled-components';
2-
import TextareaAutosize from 'react-textarea-autosize';
32

43
type InputProps = {
54
error?: boolean;
@@ -130,7 +129,8 @@ const sharedInputStyles = css<SharedInputStylesProps>`
130129

131130
type MultiLineInputProps = SharedInputStylesProps;
132131

133-
export const MultiLineInput = styled(TextareaAutosize)<MultiLineInputProps>`
132+
export const MultiLineInput = styled.textarea<MultiLineInputProps>`
133+
field-sizing: content;
134134
${sharedInputStyles}
135135
`;
136136

src/views/components/modals/UnsavedWarningModal.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,23 @@ export const UnsavedWarningModal = () => {
7272
};
7373
// eslint-disable-next-line react-hooks/exhaustive-deps
7474
}, [isDataToSave, state]);
75+
useEffect(() => {
76+
const handleKeyDown = (event: KeyboardEvent) => {
77+
if (event.key === 'Tab' || (event.shiftKey && event.key === 'Tab') || (event.ctrlKey && event.key === 'a')) {
78+
event.preventDefault();
79+
}
80+
};
81+
82+
if (show) {
83+
document.addEventListener('keydown', handleKeyDown);
84+
} else {
85+
document.removeEventListener('keydown', handleKeyDown);
86+
}
87+
88+
return () => {
89+
document.removeEventListener('keydown', handleKeyDown);
90+
};
91+
}, [show]);
7592

7693
return show ? (
7794
<OverlayContainer className="active">

0 commit comments

Comments
 (0)