diff --git a/src/components/core/modal/basic-modal.js b/src/components/core/modal/basic-modal.js index 3062d60..34e9341 100644 --- a/src/components/core/modal/basic-modal.js +++ b/src/components/core/modal/basic-modal.js @@ -16,8 +16,24 @@ const BasicModal = ({ cancelLabel = 'Cancel', confirmLabel = 'Confirm', size = 'l', + asForm = false, children, }) => { + // When asForm, the body and footer live inside a
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 ( @@ -39,19 +55,33 @@ const BasicModal = ({ > {title} -
{children}
-
- {hasCancel && ( - - )} - {hasConfirm && ( - - )} -
+ +
{children}
+
+ {hasCancel && ( + + )} + {hasConfirm && ( + + )} +
+
@@ -71,6 +101,7 @@ BasicModal.propTypes = { hasCancel: PropTypes.bool, hasConfirm: PropTypes.bool, size: PropTypes.oneOf(['sm', 'l']), + asForm: PropTypes.bool, }; export default BasicModal; diff --git a/src/components/iframe-input-modal.js b/src/components/iframe-input-modal.js index e69bf40..56f9b8c 100644 --- a/src/components/iframe-input-modal.js +++ b/src/components/iframe-input-modal.js @@ -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'; @@ -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 = () => { @@ -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(); @@ -47,10 +52,12 @@ const IframeInputModal = ({ isOpen, onClose, onConfirm }) => { onConfirm={handleConfirm} cancelLabel={t('cancel')} confirmLabel={t('confirm')} + asForm >
{ @@ -64,6 +71,7 @@ const IframeInputModal = ({ isOpen, onClose, onConfirm }) => { { +const ImageSource = React.forwardRef(({ onChange, error, onErrorClear }, ref) => { const [uploadFile, setUploadFile] = useState(null); const [uploadError, setUploadError] = useState(false); const [embedUrl, setEmbedUrl] = useState(''); @@ -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); @@ -260,6 +264,8 @@ const ImageSource = ({ onChange, error, onErrorClear }) => {
{ @@ -283,7 +289,9 @@ const ImageSource = ({ onChange, error, onErrorClear }) => {
); -}; +}); + +ImageSource.displayName = 'ImageSource'; ImageSource.propTypes = { onChange: PropTypes.func.isRequired, @@ -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 = () => { @@ -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, @@ -359,10 +374,12 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => { onConfirm={handleConfirm} cancelLabel={t('cancel')} confirmLabel={t('confirm')} + asForm >
{/* Image source */} { setImageSource(src); if (src) setSourceError(''); @@ -374,6 +391,7 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => { {/* Alt text */} { @@ -412,6 +430,8 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => { {linkOption === 'with-link' && ( { diff --git a/src/components/link-input-modal.js b/src/components/link-input-modal.js index ff28a42..404b21b 100644 --- a/src/components/link-input-modal.js +++ b/src/components/link-input-modal.js @@ -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'; @@ -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 = () => { @@ -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()}]]` : ''; @@ -55,10 +60,12 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => { onConfirm={handleConfirm} cancelLabel={t('cancel')} confirmLabel={t('confirm')} + asForm >
{ @@ -79,6 +86,8 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => { /> {