diff --git a/public/locales/en/upload-image-modal.json b/public/locales/en/upload-image-modal.json index e0f9059..7179e77 100644 --- a/public/locales/en/upload-image-modal.json +++ b/public/locales/en/upload-image-modal.json @@ -12,6 +12,7 @@ "removeImage": "Remove Image", "optional": "(Optional)", "altTextRequired": "Alt text is required", + "imageSourceRequired": "Please upload an image or enter a URL", "dropTitle": "Drag & drop to upload", "dropMeta": "Max size: {{maxSize}}MB Recommended: 1200 x 800 px Formats: jpg/jpeg, png", "uploadFailed": "Image upload failed", diff --git a/public/locales/zh-TW/upload-image-modal.json b/public/locales/zh-TW/upload-image-modal.json index ec99bda..9a4f6fa 100644 --- a/public/locales/zh-TW/upload-image-modal.json +++ b/public/locales/zh-TW/upload-image-modal.json @@ -12,6 +12,7 @@ "removeImage": "移除圖片", "optional": "(選填)", "altTextRequired": "請輸入替代文字", + "imageSourceRequired": "請填寫圖片 URL 或上傳圖片", "dropTitle": "拖曳檔案上傳", "dropMeta": "大小限制:{{maxSize}}MB 建議尺寸:1200 x 800 px 支援格式:jpg/jpeg, png", "uploadFailed": "圖片上傳失敗", diff --git a/src/components/core/button/primary-button.js b/src/components/core/button/primary-button.js index 2254eea..5d66b90 100644 --- a/src/components/core/button/primary-button.js +++ b/src/components/core/button/primary-button.js @@ -7,30 +7,33 @@ const sizeClasses = { l: 'px-4 py-3 text-base leading-[1.5] disabled:px-[15px] disabled:py-[11px]', }; -const PrimaryButton = React.forwardRef(({ size = 'sm', className, children, ...props }, ref) => { - return ( - - ); -}); +const PrimaryButton = React.forwardRef( + ({ size = 'sm', type = 'button', className, children, ...props }, ref) => { + return ( + + ); + } +); PrimaryButton.displayName = 'PrimaryButton'; PrimaryButton.propTypes = { size: PropTypes.oneOf(['sm', 'l']), + type: PropTypes.string, className: PropTypes.string, children: PropTypes.node, }; diff --git a/src/components/core/button/secondary-button.js b/src/components/core/button/secondary-button.js index 38f35f6..985e767 100644 --- a/src/components/core/button/secondary-button.js +++ b/src/components/core/button/secondary-button.js @@ -7,30 +7,33 @@ const sizeClasses = { l: 'px-[15px] py-[11px] text-base leading-[1.5]', }; -const SecondaryButton = React.forwardRef(({ size = 'sm', className, children, ...props }, ref) => { - return ( - - ); -}); +const SecondaryButton = React.forwardRef( + ({ size = 'sm', type = 'button', className, children, ...props }, ref) => { + return ( + + ); + } +); SecondaryButton.displayName = 'SecondaryButton'; SecondaryButton.propTypes = { size: PropTypes.oneOf(['sm', 'l']), + type: PropTypes.string, className: PropTypes.string, children: PropTypes.node, }; diff --git a/src/components/core/modal/basic-modal.js b/src/components/core/modal/basic-modal.js index 3062d60..6f8effb 100644 --- a/src/components/core/modal/basic-modal.js +++ b/src/components/core/modal/basic-modal.js @@ -15,6 +15,8 @@ const BasicModal = ({ hasConfirm = true, cancelLabel = 'Cancel', confirmLabel = 'Confirm', + confirmType = 'button', + confirmForm, size = 'l', children, }) => { @@ -47,7 +49,14 @@ const BasicModal = ({ )} {hasConfirm && ( - )} @@ -66,6 +75,8 @@ BasicModal.propTypes = { cancelLabel: PropTypes.string, onConfirm: PropTypes.func, confirmLabel: PropTypes.string, + confirmType: PropTypes.oneOf(['button', 'submit']), + confirmForm: PropTypes.string, title: PropTypes.string, children: PropTypes.node, hasCancel: PropTypes.bool, diff --git a/src/components/home/setting-modal/index.js b/src/components/home/setting-modal/index.js index 7f04330..ee9c8ce 100644 --- a/src/components/home/setting-modal/index.js +++ b/src/components/home/setting-modal/index.js @@ -29,6 +29,11 @@ const SettingModal = ({ isOpen, onClose, onSubmit, displayConfig, exportType, se onClose(); }, [onSubmit, onClose, localConfig, exportType, fileName]); + const handleFormSubmit = (e) => { + e.preventDefault(); + onConfirm(); + }; + return ( -
+
updateLocalConfig('documentColor', val)} /> -
+
); }; diff --git a/src/components/iframe-input-modal.js b/src/components/iframe-input-modal.js index e69bf40..d66c8d9 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,24 +34,35 @@ 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) { + if (isTitleEmpty) titleRef.current?.focus(); + else urlRef.current?.focus(); + return; + } onConfirm(title.trim(), url.trim()); handleClose(); }; + const handleFormSubmit = (e) => { + e.preventDefault(); + handleConfirm(); + }; + return ( -
+
{ /> { error={urlError} required /> -
+
); }; diff --git a/src/components/image-upload-modal.js b/src/components/image-upload-modal.js index 779b869..015efcf 100644 --- a/src/components/image-upload-modal.js +++ b/src/components/image-upload-modal.js @@ -1,5 +1,6 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; +import cn from 'classnames'; import { IconAlertTriangle, IconPhoto, IconPlus, IconX } from '@tabler/icons-react'; import { useTranslation } from '@/lib/i18n'; import { ellipsizeMiddle, isValidUrl } from '@/lib/url'; @@ -10,7 +11,7 @@ import RadioGroup from '@/components/core/radio-group'; const MAX_FILE_SIZE_MB = 10; -const ImageSource = ({ onChange }) => { +const ImageSource = ({ onChange, error, embedUrlRef }) => { const [uploadFile, setUploadFile] = useState(null); const [uploadError, setUploadError] = useState(false); const [embedUrl, setEmbedUrl] = useState(''); @@ -199,7 +200,13 @@ const ImageSource = ({ onChange }) => { }; return ( -
+
{t('imageSource')} {/* Status message for screen readers */} @@ -257,6 +264,7 @@ const ImageSource = ({ onChange }) => {
{
+ + {error && ( + + )}
); }; ImageSource.propTypes = { onChange: PropTypes.func.isRequired, + error: PropTypes.string, + embedUrlRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.any })]), }; const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => { const [imageSource, setImageSource] = useState(null); + const [imageSourceError, setImageSourceError] = useState(''); const [altText, setAltText] = useState(''); const [altTextError, setAltTextError] = useState(''); const [caption, setCaption] = useState(''); const [linkOption, setLinkOption] = useState('no-link'); const [targetUrl, setTargetUrl] = useState(''); const [targetUrlError, setTargetUrlError] = useState(''); + const embedUrlRef = useRef(null); + const altTextRef = useRef(null); + const targetUrlRef = useRef(null); const t = useTranslation('upload-image-modal'); const resetForm = () => { setImageSource(null); + setImageSourceError(''); setAltText(''); setAltTextError(''); setCaption(''); @@ -310,23 +331,22 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => { }; const handleConfirm = () => { - if (!imageSource) return; // TODO: ask designer where to show image validation error - - let valid = true; - if (!altText.trim()) { - setAltTextError(t('altTextRequired')); - valid = false; - } - if (linkOption === 'with-link') { - if (!targetUrl.trim()) { - setTargetUrlError(t('targetUrlRequired')); - valid = false; - } else if (!isValidUrl(targetUrl)) { - setTargetUrlError(t('targetUrlInvalid')); - valid = false; - } + const isImageSourceEmpty = !imageSource; + const isAltTextEmpty = !altText.trim(); + const isTargetUrlEmpty = linkOption === 'with-link' && !targetUrl.trim(); + const isTargetUrlInvalid = + linkOption === 'with-link' && !isTargetUrlEmpty && !isValidUrl(targetUrl); + + if (isImageSourceEmpty) setImageSourceError(t('imageSourceRequired')); + if (isAltTextEmpty) setAltTextError(t('altTextRequired')); + if (isTargetUrlEmpty) setTargetUrlError(t('targetUrlRequired')); + else if (isTargetUrlInvalid) setTargetUrlError(t('targetUrlInvalid')); + if (isImageSourceEmpty || isAltTextEmpty || isTargetUrlEmpty || isTargetUrlInvalid) { + if (isImageSourceEmpty) embedUrlRef.current?.focus(); + else if (isAltTextEmpty) altTextRef.current?.focus(); + else targetUrlRef.current?.focus(); + return; } - if (!valid) return; onConfirm({ file: imageSource.file, @@ -338,22 +358,41 @@ const ImageUploadModal = ({ isOpen, onClose, onConfirm }) => { handleClose(); }; + const handleFormSubmit = (e) => { + e.preventDefault(); + handleConfirm(); + }; + return ( -
+
{/* Image source */} - + { + setImageSource(source); + if (source) setImageSourceError(''); + }} + /> {/* Alt text */} { /> {linkOption === 'with-link' && ( { required /> )} -
+
); }; diff --git a/src/components/link-input-modal.js b/src/components/link-input-modal.js index ff28a42..2a5a490 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,11 @@ 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) { + if (isDisplayEmpty) displayRef.current?.focus(); + else urlRef.current?.focus(); + return; + } const prefix = openInNewTab ? '@' : ''; const titlePart = title.trim() ? `[[${title.trim()}]]` : ''; @@ -46,18 +52,25 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => { handleClose(); }; + const handleFormSubmit = (e) => { + e.preventDefault(); + handleConfirm(); + }; + return ( -
+
+
); };