initial commit after split

This commit is contained in:
2026-06-24 01:43:10 +02:00
parent b1d6c0150f
commit 30c11a6dcf
173 changed files with 25380 additions and 0 deletions

View 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>
);
}