initial commit after split
This commit is contained in:
246
webui/src/components/email/EmailAddressInput.tsx
Normal file
246
webui/src/components/email/EmailAddressInput.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Button from "../Button";
|
||||
import {
|
||||
addressDisplayName,
|
||||
dedupeAddresses,
|
||||
isValidEmailAddress,
|
||||
normalizeEmailAddress,
|
||||
parseMailboxAddressText,
|
||||
type MailboxAddress
|
||||
} from "../../utils/emailAddresses";
|
||||
|
||||
type EmailAddressInputProps = {
|
||||
value: MailboxAddress[];
|
||||
onChange?: (value: MailboxAddress[]) => void;
|
||||
onAddressAdded?: (address: MailboxAddress) => void;
|
||||
suggestions?: MailboxAddress[];
|
||||
allowMultiple?: boolean;
|
||||
clearOnAdd?: boolean;
|
||||
disabled?: boolean;
|
||||
addLabel?: string;
|
||||
namePlaceholder?: string;
|
||||
emailPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
compact?: boolean;
|
||||
showAddButton?: boolean;
|
||||
};
|
||||
|
||||
export default function EmailAddressInput({
|
||||
value,
|
||||
onChange,
|
||||
onAddressAdded,
|
||||
suggestions = [],
|
||||
allowMultiple = true,
|
||||
clearOnAdd = false,
|
||||
disabled = false,
|
||||
addLabel = "Add",
|
||||
namePlaceholder = "Name",
|
||||
emailPlaceholder = "email@example.org",
|
||||
emptyText = "No address added yet.",
|
||||
compact = false,
|
||||
showAddButton
|
||||
}: EmailAddressInputProps) {
|
||||
const inputId = useId();
|
||||
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
|
||||
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
|
||||
const [entryText, setEntryText] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogName, setDialogName] = useState("");
|
||||
const [dialogEmail, setDialogEmail] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
|
||||
const addButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const canUseAddButton = showAddButton ?? allowMultiple;
|
||||
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
const query = entryText.trim().toLowerCase();
|
||||
if (!query) return normalizedSuggestions.slice(0, 6);
|
||||
return normalizedSuggestions
|
||||
.filter((item) => `${item.name ?? ""} ${item.email}`.toLowerCase().includes(query))
|
||||
.slice(0, 6);
|
||||
}, [entryText, normalizedSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
|
||||
function updatePopoverPosition() {
|
||||
const trigger = addButtonRef.current;
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const viewportPadding = 16;
|
||||
const width = Math.min(360, Math.max(280, window.innerWidth - viewportPadding * 2));
|
||||
const estimatedHeight = 250;
|
||||
const left = Math.min(Math.max(viewportPadding, rect.right - width), window.innerWidth - width - viewportPadding);
|
||||
const belowTop = rect.bottom + 8;
|
||||
const aboveTop = rect.top - estimatedHeight - 8;
|
||||
const top = belowTop + estimatedHeight > window.innerHeight && aboveTop > viewportPadding ? aboveTop : belowTop;
|
||||
setPopoverStyle({
|
||||
position: "fixed",
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
zIndex: 10000
|
||||
});
|
||||
}
|
||||
|
||||
updatePopoverPosition();
|
||||
window.addEventListener("resize", updatePopoverPosition);
|
||||
window.addEventListener("scroll", updatePopoverPosition, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePopoverPosition);
|
||||
window.removeEventListener("scroll", updatePopoverPosition, true);
|
||||
};
|
||||
}, [dialogOpen]);
|
||||
|
||||
function removeAddress(emailToRemove: string) {
|
||||
if (disabled) return;
|
||||
onChange?.(normalizedValue.filter((address) => address.email !== emailToRemove));
|
||||
setError("");
|
||||
}
|
||||
|
||||
function commitAddress(candidate: MailboxAddress | null, sourceText = "") {
|
||||
if (disabled) return false;
|
||||
if (!candidate?.email) {
|
||||
setError(sourceText ? "Use a valid address such as Name <email@example.org>." : "Enter an email address first.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = normalizeEmailAddress(candidate);
|
||||
if (!isValidEmailAddress(normalized.email)) {
|
||||
setError("Enter a valid email address.");
|
||||
return false;
|
||||
}
|
||||
if (allowMultiple && normalizedValue.some((address) => address.email === normalized.email)) {
|
||||
setError("This address is already listed.");
|
||||
return false;
|
||||
}
|
||||
|
||||
onAddressAdded?.(normalized);
|
||||
if (!clearOnAdd) {
|
||||
const nextValue = allowMultiple ? dedupeAddresses([...normalizedValue, normalized]) : [normalized];
|
||||
onChange?.(nextValue);
|
||||
}
|
||||
setEntryText("");
|
||||
setDialogName("");
|
||||
setDialogEmail("");
|
||||
setDialogOpen(false);
|
||||
setError("");
|
||||
return true;
|
||||
}
|
||||
|
||||
function commitTypedAddress() {
|
||||
const text = entryText.trim();
|
||||
if (!text) return;
|
||||
commitAddress(parseMailboxAddressText(text), text);
|
||||
}
|
||||
|
||||
function commitDialogAddress() {
|
||||
commitAddress({ name: dialogName, email: dialogEmail }, `${dialogName} ${dialogEmail}`);
|
||||
}
|
||||
|
||||
function handleTextKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
commitTypedAddress();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Backspace" && !entryText && normalizedValue.length > 0 && !disabled) {
|
||||
event.preventDefault();
|
||||
const last = normalizedValue[normalizedValue.length - 1];
|
||||
removeAddress(last.email);
|
||||
}
|
||||
}
|
||||
|
||||
function applySuggestion(address: MailboxAddress) {
|
||||
commitAddress(address);
|
||||
}
|
||||
|
||||
const addressDialog = dialogOpen && canUseAddButton && !disabled ? createPortal(
|
||||
<div
|
||||
className="email-address-popover"
|
||||
style={popoverStyle}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby={`${inputId}-dialog-title`}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
<h4 id={`${inputId}-dialog-title`}>Add address</h4>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
<span>Email address</span>
|
||||
<input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" />
|
||||
</label>
|
||||
<div className="button-row compact-actions">
|
||||
<Button type="button" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
|
||||
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
|
||||
<div className="email-chip-list" aria-live="polite">
|
||||
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{emptyText}</span>}
|
||||
{normalizedValue.map((address) => {
|
||||
const valid = isValidEmailAddress(address.email);
|
||||
return (
|
||||
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "Invalid email address"}>
|
||||
<span className="email-chip-main">{addressDisplayName(address)}</span>
|
||||
{address.name && <span className="email-chip-address">{address.email}</span>}
|
||||
{!disabled && (
|
||||
<button type="button" className="email-chip-remove" aria-label={`Remove ${address.email}`} onClick={() => removeAddress(address.email)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<>
|
||||
<textarea
|
||||
id={inputId}
|
||||
className="email-address-textarea"
|
||||
rows={1}
|
||||
value={entryText}
|
||||
onChange={(event) => {
|
||||
setEntryText(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={handleTextKeyDown}
|
||||
placeholder={`${namePlaceholder} <${emailPlaceholder}>`}
|
||||
aria-label="Type a name and email address, then press Enter"
|
||||
/>
|
||||
{canUseAddButton && (
|
||||
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="Open address form" title="Add address with form" onClick={() => setDialogOpen((open) => !open)}>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!disabled && filteredSuggestions.length > 0 && entryText.trim() && (
|
||||
<div className="email-address-suggestions" role="listbox" aria-label="Address suggestions">
|
||||
{filteredSuggestions.map((item) => (
|
||||
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
|
||||
<span>{addressDisplayName(item)}</span>
|
||||
{item.name && <small>{item.email}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="form-help danger-text">{error}</p>}
|
||||
{addressDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user