Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
210 lines
5.6 KiB
TypeScript
210 lines
5.6 KiB
TypeScript
import { FocusScope } from '@react-aria/focus';
|
|
import {
|
|
Modal,
|
|
AnimatedVisibility,
|
|
Button,
|
|
Box,
|
|
ModalHeader,
|
|
ModalThumb,
|
|
ModalTitle,
|
|
ModalClose,
|
|
ModalContent,
|
|
ModalFooter,
|
|
ModalFooterControllers,
|
|
} from '@rocket.chat/fuselage';
|
|
import { UiKitComponent, UiKitModal, modalParser } from '@rocket.chat/fuselage-ui-kit';
|
|
import type * as UiKit from '@rocket.chat/ui-kit';
|
|
import type { FormEvent, FormEventHandler } from 'react';
|
|
import { useId, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
|
|
import { getButtonStyle } from './getButtonStyle';
|
|
import { getURL } from '../../../lib/getURL';
|
|
|
|
const focusableElementsString = `
|
|
a[href]:not([tabindex="-1"]),
|
|
area[href]:not([tabindex="-1"]),
|
|
input:not([disabled]):not([tabindex="-1"]),
|
|
select:not([disabled]):not([tabindex="-1"]),
|
|
textarea:not([disabled]):not([tabindex="-1"]),
|
|
button:not([disabled]):not([tabindex="-1"]),
|
|
iframe,
|
|
object,
|
|
embed,
|
|
[tabindex]:not([tabindex="-1"]),
|
|
[contenteditable]`;
|
|
|
|
const focusableElementsStringInvalid = `
|
|
a[href]:not([tabindex="-1"]):invalid,
|
|
area[href]:not([tabindex="-1"]):invalid,
|
|
input:not([disabled]):not([tabindex="-1"]):invalid,
|
|
select:not([disabled]):not([tabindex="-1"]):invalid,
|
|
textarea:not([disabled]):not([tabindex="-1"]):invalid,
|
|
button:not([disabled]):not([tabindex="-1"]):invalid,
|
|
iframe:invalid,
|
|
object:invalid,
|
|
embed:invalid,
|
|
[tabindex]:not([tabindex="-1"]):invalid,
|
|
[contenteditable]:invalid`;
|
|
|
|
type ModalBlockParams = {
|
|
view: UiKit.ModalView;
|
|
errors: any;
|
|
appId: string;
|
|
onSubmit: (event: FormEvent) => void;
|
|
onClose: () => void;
|
|
onCancel: FormEventHandler;
|
|
};
|
|
|
|
const isFocusable = (element: Element | null): element is HTMLElement =>
|
|
element !== null && 'focus' in element && typeof element.focus === 'function';
|
|
|
|
const KeyboardCode = new Map<string, number>([
|
|
['ENTER', 13],
|
|
['ESC', 27],
|
|
['TAB', 9],
|
|
]);
|
|
|
|
const ModalBlock = ({ view, errors, onSubmit, onClose, onCancel }: ModalBlockParams) => {
|
|
const id = `modal_id_${useId()}`;
|
|
const ref = useRef<HTMLElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!ref.current) {
|
|
return;
|
|
}
|
|
|
|
if (errors && Object.keys(errors).length) {
|
|
const element = ref.current.querySelector<HTMLElement>(focusableElementsStringInvalid);
|
|
element?.focus();
|
|
} else {
|
|
const element = ref.current.querySelector<HTMLElement>(focusableElementsString);
|
|
element?.focus();
|
|
}
|
|
}, [errors]);
|
|
|
|
const previousFocus = useMemo(() => document.activeElement, []);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (previousFocus && isFocusable(previousFocus)) {
|
|
return previousFocus.focus();
|
|
}
|
|
},
|
|
[previousFocus],
|
|
);
|
|
|
|
const formRef = useRef<HTMLFormElement>(null);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(event: KeyboardEvent) => {
|
|
switch (event.keyCode) {
|
|
case KeyboardCode.get('ENTER'):
|
|
if ((event?.target as Node | null)?.nodeName !== 'TEXTAREA') {
|
|
formRef.current?.submit();
|
|
return;
|
|
}
|
|
return;
|
|
case KeyboardCode.get('ESC'):
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
onClose();
|
|
return;
|
|
case KeyboardCode.get('TAB'):
|
|
if (!ref.current) {
|
|
return;
|
|
}
|
|
const elements = Array.from(ref.current.querySelectorAll<HTMLElement>(focusableElementsString));
|
|
const [first] = elements;
|
|
const last = elements.pop();
|
|
|
|
if (!ref.current.contains(document.activeElement)) {
|
|
return first.focus();
|
|
}
|
|
|
|
if (event.shiftKey) {
|
|
if (!first || first === document.activeElement) {
|
|
last?.focus();
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!last || last === document.activeElement) {
|
|
first.focus();
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
},
|
|
[onClose],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const element = document.querySelector('#modal-root') as HTMLElement;
|
|
const container = element.querySelector('.rcx-modal__content') as HTMLElement;
|
|
const close = (e: Event) => {
|
|
if (e.target !== element) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onClose();
|
|
return false;
|
|
};
|
|
|
|
const ignoreIfNotContains = (e: KeyboardEvent) => {
|
|
if (e.target !== element) {
|
|
return;
|
|
}
|
|
|
|
if (!container.contains(e.target as HTMLElement)) {
|
|
return;
|
|
}
|
|
return handleKeyDown(e);
|
|
};
|
|
|
|
document.addEventListener('keydown', ignoreIfNotContains);
|
|
element.addEventListener('click', close);
|
|
return () => {
|
|
document.removeEventListener('keydown', ignoreIfNotContains);
|
|
element.removeEventListener('click', close);
|
|
};
|
|
}, [handleKeyDown, onClose]);
|
|
|
|
return (
|
|
<AnimatedVisibility visibility={AnimatedVisibility.UNHIDING}>
|
|
<FocusScope contain restoreFocus autoFocus>
|
|
<Modal aria-labelledby={`${id}-title`} open id={id} ref={ref}>
|
|
<ModalHeader>
|
|
{view.showIcon ? <ModalThumb url={getURL(`/api/apps/${view.appId}/icon`)} /> : null}
|
|
<ModalTitle id={`${id}-title`}>{modalParser.renderTextObject(view.title, 0)}</ModalTitle>
|
|
<ModalClose tabIndex={-1} onClick={onClose} />
|
|
</ModalHeader>
|
|
<ModalContent>
|
|
<Box ref={formRef} is='form' method='post' action='#' onSubmit={onSubmit}>
|
|
<UiKitComponent render={UiKitModal} blocks={view.blocks} />
|
|
</Box>
|
|
</ModalContent>
|
|
<ModalFooter>
|
|
<ModalFooterControllers>
|
|
{view.close && (
|
|
<Button danger={view.close.style === 'danger'} onClick={onCancel}>
|
|
{modalParser.renderTextObject(view.close.text, 0)}
|
|
</Button>
|
|
)}
|
|
{view.submit && (
|
|
<Button {...getButtonStyle(view.submit)} onClick={onSubmit}>
|
|
{modalParser.renderTextObject(view.submit.text, 1)}
|
|
</Button>
|
|
)}
|
|
</ModalFooterControllers>
|
|
</ModalFooter>
|
|
</Modal>
|
|
</FocusScope>
|
|
</AnimatedVisibility>
|
|
);
|
|
};
|
|
|
|
export default ModalBlock;
|