first wokring prototype

This commit is contained in:
2026-06-10 04:10:02 +02:00
parent 50d779a537
commit 7491c0a1b4
90 changed files with 10799 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "ghost" | "danger" };
export default function Button({ variant = "secondary", className = "", ...props }: Props) {
return <button className={`btn btn-${variant} ${className}`} {...props} />;
}

19
src/components/Card.tsx Normal file
View File

@@ -0,0 +1,19 @@
type CardProps = {
title?: React.ReactNode;
children: React.ReactNode;
actions?: React.ReactNode;
};
export default function Card({ title, children, actions }: CardProps) {
return (
<section className="card">
{(title || actions) && (
<header className="card-header">
{title && (typeof title === "string" ? <h2>{title}</h2> : <div className="card-title-node">{title}</div>)}
{actions && <div className="card-actions">{actions}</div>}
</header>
)}
<div className="card-body">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,12 @@
import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel";
import { helpForFieldLabel } from "../utils/fieldHelp";
export default function FormField({ label, help, children }: { label: ReactNode; help?: ReactNode; children: ReactNode }) {
return (
<label className="form-field">
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)}>{label}</FieldLabel>
{children}
</label>
);
}

View File

@@ -0,0 +1,12 @@
type LoadingIndicatorProps = {
label?: string;
size?: "sm" | "md";
};
export default function LoadingIndicator({ label = "Loading", size = "sm" }: LoadingIndicatorProps) {
return (
<span className={`loading-indicator loading-indicator-${size}`} role="status" aria-label={label} title={label}>
<span className="loading-envelope" aria-hidden="true" />
</span>
);
}

View File

@@ -0,0 +1,9 @@
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
return (
<div className={`metric-card metric-${tone}`}>
<div className="metric-label">{label}</div>
<div className="metric-value">{value}</div>
{detail && <div className="metric-detail">{detail}</div>}
</div>
);
}

View File

@@ -0,0 +1,15 @@
import LoadingIndicator from "./LoadingIndicator";
type PageTitleProps = {
children: React.ReactNode;
loading?: boolean;
};
export default function PageTitle({ children, loading = false }: PageTitleProps) {
return (
<h1 className="page-title-with-loader">
<span>{children}</span>
{loading && <LoadingIndicator label="Loading page data" />}
</h1>
);
}

View File

@@ -0,0 +1,3 @@
export default function StatusBadge({ status }: { status: string }) {
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{status}</span>;
}

View File

@@ -0,0 +1,19 @@
import type { WizardStep } from "../types";
export default function Stepper({ steps, activeStep, onSelect }: { steps: WizardStep[]; activeStep: string; onSelect: (id: string) => void }) {
return (
<ol className="stepper">
{steps.map((step, index) => (
<li key={step.id} className={`step ${activeStep === step.id ? "active" : ""} step-${step.status || "todo"}`}>
<button onClick={() => onSelect(step.id)}>
<span className="step-number">{index + 1}</span>
<span>
<strong>{step.label}</strong>
{step.description && <small>{step.description}</small>}
</span>
</button>
</li>
))}
</ol>
);
}

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel";
import { helpForFieldLabel } from "../utils/fieldHelp";
type ToggleSwitchProps = {
label: ReactNode;
checked: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
help?: ReactNode;
};
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
return (
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
<input
className="toggle-switch-input"
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange?.(event.target.checked)}
/>
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
<span className="toggle-switch-copy">
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{label}</FieldLabel>
</span>
</label>
);
}

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

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import InlineHelp from "./InlineHelp";
type FieldLabelProps = {
children: ReactNode;
help?: ReactNode;
className?: string;
};
export default function FieldLabel({ children, help, className = "" }: FieldLabelProps) {
return (
<span className={`field-label ${className}`.trim()}>
<span className="field-label-text">{children}</span>
{help && <InlineHelp>{help}</InlineHelp>}
</span>
);
}

View File

@@ -0,0 +1,186 @@
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
type InlineHelpProps = {
children: ReactNode;
className?: string;
};
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
const OPEN_DELAY_MS = 350;
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const tooltipBaseStyle: CSSProperties = {
position: "fixed",
zIndex: 10000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
border: "1px solid var(--line-dark)",
borderRadius: 7,
background: "var(--surface)",
boxShadow: "var(--shadow-popover)",
color: "var(--text)",
fontSize: 12,
fontWeight: 500,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
export default function InlineHelp({ children, className = "" }: InlineHelpProps) {
const tooltipId = useId();
const triggerRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, OPEN_DELAY_MS);
}, [clearOpenTimer]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
const top = hasRoomAbove
? topCandidate
: clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
if (!children) return null;
const tooltipStyle: CSSProperties = {
...tooltipBaseStyle,
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: position ? 1 : 0
};
const arrowStyle: CSSProperties = position?.placement === "bottom"
? {
position: "absolute",
left: position.arrowLeft,
top: 0,
width: 9,
height: 9,
borderLeft: "1px solid var(--line-dark)",
borderTop: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
}
: {
position: "absolute",
left: position?.arrowLeft ?? 16,
top: "100%",
width: 9,
height: 9,
borderRight: "1px solid var(--line-dark)",
borderBottom: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
};
return (
<>
<span
ref={triggerRef}
className={`inline-help ${className}`.trim()}
tabIndex={0}
aria-label="Show field help"
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openWithDelay}
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}
>
<span className="inline-help-mark" aria-hidden="true">?</span>
</span>
{isOpen && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{children}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>
);
}