Skip to content
Open
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
4 changes: 3 additions & 1 deletion public/locales/en/link-input-modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@
"newTab": "Open in new tab",
"currentTab": "Open in current window",
"cancel": "Cancel",
"confirm": "Confirm"
"confirm": "Confirm",
"displayRequiredError": "Link Display Text is required",
"urlRequiredError": "Link URL is required"
}
4 changes: 3 additions & 1 deletion public/locales/zh-TW/link-input-modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@
"newTab": "在新分頁中開啟",
"currentTab": "在當前視窗中開啟",
"cancel": "取消",
"confirm": "確認"
"confirm": "確認",
"displayRequiredError": "文字連結標題為必填",
"urlRequiredError": "文字連結 URL 為必填"
}
60 changes: 60 additions & 0 deletions src/components/core/radio-group.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';

const RadioGroup = ({ name, label, options, value, onChange }) => {
return (
<div className="flex flex-col gap-2">
{label && <span className="text-text-primary text-base">{label}</span>}
<div className="flex items-center">
{options.map((option) => (
<div key={option.value} className="flex flex-1 items-center">
<label
className={cn(
'flex items-center gap-3 px-1.5 py-1 rounded-lg',
'focus-within:outline focus-within:outline-2 focus-within:outline-primary',
option.disabled ? 'cursor-not-allowed' : 'cursor-pointer'
)}
>
<input
type="radio"
name={name}
checked={value === option.value}
onChange={() => onChange(option.value)}
disabled={option.disabled}
className={cn(
'appearance-none w-5 h-5 shrink-0 rounded-full border-2',
'checked:shadow-[inset_0_0_0_3px_white] focus:outline-none',
option.disabled
? 'border-gray-200 checked:bg-gray-200 checked:border-gray-200 cursor-not-allowed'
: 'border-gray-400 checked:border-primary checked:bg-primary cursor-pointer'
)}
/>
<span
className={cn('text-base', option.disabled ? 'text-gray-400' : 'text-text-primary')}
>
{option.label}
</span>
</label>
</div>
))}
</div>
</div>
);
};

RadioGroup.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
disabled: PropTypes.bool,
})
).isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};

export default RadioGroup;
78 changes: 78 additions & 0 deletions src/components/core/text-input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';

const TextInput = React.forwardRef(
(
{
id,
type = 'text',
value,
onChange,
placeholder,
disabled = false,
error,
required,
label,
hint,
className,
...props
},
ref
) => {
return (
<div className="flex flex-col gap-2">
{label && (
<label htmlFor={id} className="flex gap-2 items-center text-text-primary text-base">
<span>{label}</span>
{hint && <span className="text-text-secondary text-sm">{hint}</span>}
</label>
)}
<input
ref={ref}
id={id}
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
aria-required={required || undefined}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
className={cn(
'w-full border rounded-lg px-4 py-3 text-base transition-colors',
'placeholder:text-text-placeholder',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
error ? 'border-error text-text-primary' : 'border-border-main text-text-primary',
disabled && 'bg-bg-disabled text-text-disabled border-border-main cursor-not-allowed',
className
)}
{...props}
/>
{error && (
<span id={`${id}-error`} role="alert" className="text-sm text-error leading-[1.4]">
{error}
</span>
)}
</div>
);
}
);

TextInput.displayName = 'TextInput';

TextInput.propTypes = {
id: PropTypes.string,
type: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
error: PropTypes.string,
required: PropTypes.bool,
label: PropTypes.string,
hint: PropTypes.string,
className: PropTypes.string,
};

export default TextInput;
126 changes: 55 additions & 71 deletions src/components/link-input-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from '@/lib/i18n';
import BasicModal from '@/components/core/modal/basic-modal';
import TextInput from '@/components/core/text-input';
import RadioGroup from '@/components/core/radio-group';

const LinkInputModal = ({ isOpen, onClose, onConfirm }) => {
const [display, setDisplay] = useState('');
const [title, setTitle] = useState('');
const [url, setUrl] = useState('');
const [openInNewTab, setOpenInNewTab] = useState(true);
const [displayError, setDisplayError] = useState('');
const [urlError, setUrlError] = useState('');
const t = useTranslation('link-input-modal');

const resetForm = () => {
setDisplay('');
setTitle('');
setUrl('');
setOpenInNewTab(true);
setDisplayError('');
setUrlError('');
};

const handleClose = () => {
Expand All @@ -23,7 +29,13 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => {
};

const handleConfirm = () => {
if (!display.trim() || !url.trim()) return;
const isDisplayEmpty = !display.trim();
const isUrlEmpty = !url.trim();

if (isDisplayEmpty) setDisplayError(t('displayRequiredError'));
if (isUrlEmpty) setUrlError(t('urlRequiredError'));
if (isDisplayEmpty || isUrlEmpty) return;

const prefix = openInNewTab ? '@' : '';
const titlePart = title.trim() ? `[[${title.trim()}]]` : '';
const markdown = `${prefix}[${display.trim()}]${titlePart}(${url.trim()})`;
Expand All @@ -42,76 +54,48 @@ const LinkInputModal = ({ isOpen, onClose, onConfirm }) => {
confirmLabel={t('confirm')}
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<label className="text-text-primary text-base" htmlFor="link-display">
{t('display')}
</label>
<input
id="link-display"
type="text"
value={display}
onChange={(e) => setDisplay(e.target.value)}
placeholder={t('displayPlaceholder')}
className="w-full border border-border-main rounded-lg px-4 py-3 text-base text-text-primary placeholder:text-text-placeholder focus:outline-none focus:ring-2 focus:ring-primary"
aria-required="true"
/>
</div>
<div className="flex flex-col gap-2">
<label
className="flex gap-2 items-center text-text-primary text-base"
htmlFor="link-title"
>
<span>{t('linkTitle')}</span>
<span className="text-text-primary">{t('optional')}</span>
</label>
<input
id="link-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('titlePlaceholder')}
className="w-full border border-border-main rounded-lg px-4 py-3 text-base text-text-primary placeholder:text-text-placeholder focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-text-primary text-base" htmlFor="link-url">
{t('url')}
</label>
<input
id="link-url"
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t('urlPlaceholder')}
className="w-full border border-border-main rounded-lg px-4 py-3 text-base text-text-primary placeholder:text-text-placeholder focus:outline-none focus:ring-2 focus:ring-primary"
aria-required="true"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-text-primary text-base">{t('openMethod')}</span>
<div className="flex items-center">
<label className="flex flex-1 items-center gap-3 py-1 cursor-pointer">
<input
type="radio"
name="link-open-method"
checked={openInNewTab}
onChange={() => setOpenInNewTab(true)}
className="accent-primary w-5 h-5 shrink-0"
/>
<span className="text-base text-text-primary">{t('newTab')}</span>
</label>
<label className="flex flex-1 items-center gap-3 px-2 py-1 cursor-pointer">
<input
type="radio"
name="link-open-method"
checked={!openInNewTab}
onChange={() => setOpenInNewTab(false)}
className="accent-primary w-5 h-5 shrink-0"
/>
<span className="text-base text-text-primary">{t('currentTab')}</span>
</label>
</div>
</div>
<TextInput
id="link-display"
label={t('display')}
value={display}
onChange={(e) => {
setDisplay(e.target.value);
setDisplayError('');
}}
placeholder={t('displayPlaceholder')}
error={displayError}
required
/>
<TextInput
id="link-title"
label={t('linkTitle')}
hint={t('optional')}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('titlePlaceholder')}
/>
<TextInput
id="link-url"
label={t('url')}
value={url}
onChange={(e) => {
setUrl(e.target.value);
setUrlError('');
}}
placeholder={t('urlPlaceholder')}
error={urlError}
required
/>
<RadioGroup
name="link-open-method"
label={t('openMethod')}
options={[
{ value: 'new-tab', label: t('newTab') },
{ value: 'current-tab', label: t('currentTab') },
]}
value={openInNewTab ? 'new-tab' : 'current-tab'}
onChange={(val) => setOpenInNewTab(val === 'new-tab')}
/>
</div>
</BasicModal>
);
Expand Down
Loading