Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 44 additions & 13 deletions src/components/core/modal/basic-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,24 @@ const BasicModal = ({
cancelLabel = 'Cancel',
confirmLabel = 'Confirm',
size = 'l',
asForm = false,
children,
}) => {
// When asForm, the body and footer live inside a <form> so Enter submits from any
// field (native implicit submission) and the confirm button carries submit semantics.
const Body = asForm ? 'form' : Fragment;
const bodyProps = asForm
? {
// Custom validation owns error messaging (translated, persistent, announced via
// role="alert"), so suppress the browser's transient native validation bubbles.
noValidate: true,
onSubmit: (e) => {
e.preventDefault();
onConfirm();
},
}
: {};

return (
<Transition.Root appear show={isOpen} as={Fragment}>
<Dialog className="fixed z-10 inset-0 overflow-y-auto px-10" onClose={onClose}>
Expand All @@ -39,19 +55,33 @@ const BasicModal = ({
>
{title}
</Dialog.Title>
<div className="mb-6">{children}</div>
<div className="flex justify-center">
{hasCancel && (
<Button className="mr-3 w-full" variant="secondary" onClick={onCancel} size={size}>
{cancelLabel}
</Button>
)}
{hasConfirm && (
<Button className="w-full" variant="primary" onClick={onConfirm} size={size}>
{confirmLabel}
</Button>
)}
</div>
<Body {...bodyProps}>
<div className="mb-6">{children}</div>
<div className="flex justify-center">
{hasCancel && (
<Button
type="button"
className="mr-3 w-full"
variant="secondary"
onClick={onCancel}
size={size}
>
{cancelLabel}
</Button>
)}
{hasConfirm && (
<Button
type={asForm ? 'submit' : 'button'}
className="w-full"
variant="primary"
onClick={asForm ? undefined : onConfirm}
size={size}
>
{confirmLabel}
</Button>
)}
</div>
</Body>
</Dialog.Panel>
</div>
</Dialog>
Expand All @@ -71,6 +101,7 @@ BasicModal.propTypes = {
hasCancel: PropTypes.bool,
hasConfirm: PropTypes.bool,
size: PropTypes.oneOf(['sm', 'l']),
asForm: PropTypes.bool,
};

export default BasicModal;
12 changes: 10 additions & 2 deletions src/components/iframe-input-modal.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from '@/lib/i18n';
import { isValidUrl } from '@/lib/url';
Expand All @@ -10,6 +10,8 @@ const IframeInputModal = ({ isOpen, onClose, onConfirm }) => {
const [url, setUrl] = useState('');
const [titleError, setTitleError] = useState('');
const [urlError, setUrlError] = useState('');
const titleRef = useRef(null);
const urlRef = useRef(null);
const t = useTranslation('iframe-input-modal');

const resetForm = () => {
Expand All @@ -32,7 +34,10 @@ const IframeInputModal = ({ isOpen, onClose, onConfirm }) => {
if (isTitleEmpty) setTitleError(t('titleRequiredError'));
if (isUrlEmpty) setUrlError(t('urlRequiredError'));
else if (isUrlInvalid) setUrlError(t('urlInvalidError'));
if (isTitleEmpty || isUrlEmpty || isUrlInvalid) return;
if (isTitleEmpty || isUrlEmpty || isUrlInvalid) {
(isTitleEmpty ? titleRef : urlRef).current?.focus();
return;
}

onConfirm(title.trim(), url.trim());
handleClose();
Expand All @@ -47,10 +52,12 @@ const IframeInputModal = ({ isOpen, onClose, onConfirm }) => {
onConfirm={handleConfirm}
cancelLabel={t('cancel')}
confirmLabel={t('confirm')}
asForm
>
<div className="flex flex-col gap-6">
<TextInput
id="iframe-title"
ref={titleRef}
label={t('titleLabel')}
value={title}
onChange={(val) => {
Expand All @@ -64,6 +71,7 @@ const IframeInputModal = ({ isOpen, onClose, onConfirm }) => {

<TextInput
id="iframe-url"
ref={urlRef}
type="url"
label={t('urlLabel')}
value={url}
Expand Down
38 changes: 29 additions & 9 deletions src/components/image-upload-modal.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, useImperativeHandle } from 'react';
import PropTypes from 'prop-types';
import { IconAlertTriangle, IconPhoto, IconPlus, IconX } from '@tabler/icons-react';
import { useTranslation } from '@/lib/i18n';
Expand All @@ -10,7 +10,7 @@ import RadioGroup from '@/components/core/radio-group';

const MAX_FILE_SIZE_MB = 10;

const ImageSource = ({ onChange, error, onErrorClear }) => {
const ImageSource = React.forwardRef(({ onChange, error, onErrorClear }, ref) => {
const [uploadFile, setUploadFile] = useState(null);
const [uploadError, setUploadError] = useState(false);
const [embedUrl, setEmbedUrl] = useState('');
Expand All @@ -20,11 +20,15 @@ const ImageSource = ({ onChange, error, onErrorClear }) => {
const [statusMessage, setStatusMessage] = useState('');

const fileInputRef = useRef(null);
const embedInputRef = useRef(null);
const blobUrlRef = useRef(null);
const loadRequestIdRef = useRef(0);

const t = useTranslation('upload-image-modal');

// The source-required error surfaces on the embed-url field, so focus it on failed submit.
useImperativeHandle(ref, () => ({ focus: () => embedInputRef.current?.focus() }), []);

const revokeBlobUrl = useCallback(() => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
Expand Down Expand Up @@ -260,6 +264,8 @@ const ImageSource = ({ onChange, error, onErrorClear }) => {
<div className="flex-1">
<TextInput
id="embed-url"
ref={embedInputRef}
type="url"
aria-describedby="embed-url-hint"
value={embedUrl}
onChange={(val) => {
Expand All @@ -283,7 +289,9 @@ const ImageSource = ({ onChange, error, onErrorClear }) => {
</div>
</fieldset>
);
};
});

ImageSource.displayName = 'ImageSource';

ImageSource.propTypes = {
onChange: PropTypes.func.isRequired,
Expand All @@ -301,6 +309,10 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => {
const [targetUrl, setTargetUrl] = useState('');
const [targetUrlError, setTargetUrlError] = useState('');

const sourceRef = useRef(null);
const altTextRef = useRef(null);
const targetUrlRef = useRef(null);

const t = useTranslation('upload-image-modal');

const resetForm = () => {
Expand All @@ -320,25 +332,28 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => {
};

const handleConfirm = () => {
let valid = true;
let firstErrorRef = null;
if (!imageSource) {
setSourceError(t('sourceRequired'));
valid = false;
firstErrorRef = firstErrorRef || sourceRef;
}
if (!altText.trim()) {
setAltTextError(t('altTextRequired'));
valid = false;
firstErrorRef = firstErrorRef || altTextRef;
}
if (linkOption === 'with-link') {
if (!targetUrl.trim()) {
setTargetUrlError(t('targetUrlRequired'));
valid = false;
firstErrorRef = firstErrorRef || targetUrlRef;
} else if (!isValidUrl(targetUrl)) {
setTargetUrlError(t('targetUrlInvalid'));
valid = false;
firstErrorRef = firstErrorRef || targetUrlRef;
}
}
if (!valid) return;
if (firstErrorRef) {
firstErrorRef.current?.focus();
return;
}

onConfirm({
file: imageSource.file,
Expand All @@ -359,10 +374,12 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => {
onConfirm={handleConfirm}
cancelLabel={t('cancel')}
confirmLabel={t('confirm')}
asForm
>
<div className="flex flex-col gap-6">
{/* Image source */}
<ImageSource
ref={sourceRef}
onChange={(src) => {
setImageSource(src);
if (src) setSourceError('');
Expand All @@ -374,6 +391,7 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => {
{/* Alt text */}
<TextInput
id="alt-text"
ref={altTextRef}
label={t('altText')}
value={altText}
onChange={(val) => {
Expand Down Expand Up @@ -412,6 +430,8 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => {
{linkOption === 'with-link' && (
<TextInput
id="target-url"
ref={targetUrlRef}
type="url"
label={t('targetUrl')}
value={targetUrl}
onChange={(val) => {
Expand Down
13 changes: 11 additions & 2 deletions src/components/link-input-modal.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from '@/lib/i18n';
import { isValidUrl } from '@/lib/url';
Expand All @@ -13,6 +13,8 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => {
const [openInNewTab, setOpenInNewTab] = useState(true);
const [displayError, setDisplayError] = useState('');
const [urlError, setUrlError] = useState('');
const displayRef = useRef(null);
const urlRef = useRef(null);
const t = useTranslation('link-input-modal');

const resetForm = () => {
Expand All @@ -37,7 +39,10 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => {
if (isDisplayEmpty) setDisplayError(t('displayRequiredError'));
if (isUrlEmpty) setUrlError(t('urlRequiredError'));
else if (isUrlInvalid) setUrlError(t('urlInvalidError'));
if (isDisplayEmpty || isUrlEmpty || isUrlInvalid) return;
if (isDisplayEmpty || isUrlEmpty || isUrlInvalid) {
(isDisplayEmpty ? displayRef : urlRef).current?.focus();
return;
}

const prefix = openInNewTab ? '@' : '';
const titlePart = title.trim() ? `[[${title.trim()}]]` : '';
Expand All @@ -55,10 +60,12 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => {
onConfirm={handleConfirm}
cancelLabel={t('cancel')}
confirmLabel={t('confirm')}
asForm
>
<div className="flex flex-col gap-6">
<TextInput
id="link-display"
ref={displayRef}
label={t('display')}
value={display}
onChange={(val) => {
Expand All @@ -79,6 +86,8 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => {
/>
<TextInput
id="link-url"
ref={urlRef}
type="url"
label={t('url')}
value={url}
onChange={(val) => {
Expand Down