From de29f3aa581b18d69c240314bd509fa9c99527f2 Mon Sep 17 00:00:00 2001 From: Wendy Yuchen Sun Date: Sun, 21 Jun 2026 16:02:09 +0800 Subject: [PATCH 1/2] feat: make iframe/image/link modal forms to support enter to submmit --- src/components/core/modal/basic-modal.js | 54 ++++++++++++++++++------ src/components/iframe-input-modal.js | 1 + src/components/image-upload-modal.js | 1 + src/components/link-input-modal.js | 1 + 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/components/core/modal/basic-modal.js b/src/components/core/modal/basic-modal.js index 3062d60..f6010e9 100644 --- a/src/components/core/modal/basic-modal.js +++ b/src/components/core/modal/basic-modal.js @@ -16,8 +16,21 @@ 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 + ? { + onSubmit: (e) => { + e.preventDefault(); + onConfirm(); + }, + } + : {}; + return ( @@ -39,19 +52,33 @@ const BasicModal = ({ > {title} -
{children}
-
- {hasCancel && ( - - )} - {hasConfirm && ( - - )} -
+ +
{children}
+
+ {hasCancel && ( + + )} + {hasConfirm && ( + + )} +
+
@@ -71,6 +98,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..033dd7d 100644 --- a/src/components/iframe-input-modal.js +++ b/src/components/iframe-input-modal.js @@ -47,6 +47,7 @@ const IframeInputModal = ({ isOpen, onClose, onConfirm }) => { onConfirm={handleConfirm} cancelLabel={t('cancel')} confirmLabel={t('confirm')} + asForm >
{ onConfirm={handleConfirm} cancelLabel={t('cancel')} confirmLabel={t('confirm')} + asForm >
{/* Image source */} diff --git a/src/components/link-input-modal.js b/src/components/link-input-modal.js index ff28a42..e204a95 100644 --- a/src/components/link-input-modal.js +++ b/src/components/link-input-modal.js @@ -55,6 +55,7 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => { onConfirm={handleConfirm} cancelLabel={t('cancel')} confirmLabel={t('confirm')} + asForm >
Date: Sun, 21 Jun 2026 21:21:17 +0800 Subject: [PATCH 2/2] feat: align URL input types and add accessible validation focus in modals Set type="url" on all URL fields (link, embed, target) for correct mobile keyboards and semantics. Add noValidate to the modal so custom, translated, role="alert" error messaging stays the single validation path instead of the browser's transient native bubbles. On a failed submit, move focus to the first invalid field across the iframe, link, and image modals so keyboard and screen-reader users are taken to the error rather than left on the submit button. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/core/modal/basic-modal.js | 3 ++ src/components/iframe-input-modal.js | 11 +++++-- src/components/image-upload-modal.js | 37 ++++++++++++++++++------ src/components/link-input-modal.js | 12 ++++++-- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/components/core/modal/basic-modal.js b/src/components/core/modal/basic-modal.js index f6010e9..34e9341 100644 --- a/src/components/core/modal/basic-modal.js +++ b/src/components/core/modal/basic-modal.js @@ -24,6 +24,9 @@ const BasicModal = ({ 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(); diff --git a/src/components/iframe-input-modal.js b/src/components/iframe-input-modal.js index 033dd7d..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(); @@ -52,6 +57,7 @@ const IframeInputModal = ({ isOpen, onClose, onConfirm }) => {
{ @@ -65,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, @@ -364,6 +379,7 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => {
{/* Image source */} { setImageSource(src); if (src) setSourceError(''); @@ -375,6 +391,7 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => { {/* Alt text */} { @@ -413,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 e204a95..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()}]]` : ''; @@ -60,6 +65,7 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => {
{ @@ -80,6 +86,8 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => { /> {