initial commit after split
This commit is contained in:
57
webui/src/components/AccessBoundary.tsx
Normal file
57
webui/src/components/AccessBoundary.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import type { AuthInfo } from "../types";
|
||||
import { isApiError } from "../api/client";
|
||||
import DismissibleAlert from "./DismissibleAlert";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
|
||||
type PermissionBoundaryProps = {
|
||||
auth: AuthInfo;
|
||||
anyOf: string[];
|
||||
fallback: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function PermissionBoundary({ auth, anyOf, fallback, children }: PermissionBoundaryProps) {
|
||||
return hasAnyScope(auth, anyOf) ? <>{children}</> : <Navigate to={fallback} replace />;
|
||||
}
|
||||
|
||||
type ResourceAccessBoundaryProps = {
|
||||
probe: () => Promise<unknown>;
|
||||
resetKey: string;
|
||||
fallback: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps resource routes tenant-agnostic. After a tenant switch the current URL
|
||||
* is probed in the new tenant context; inaccessible or missing resources fall
|
||||
* back to their parent collection instead of requiring switch-specific routing.
|
||||
*/
|
||||
export function ResourceAccessBoundary({ probe, resetKey, fallback, children }: ResourceAccessBoundaryProps) {
|
||||
const [state, setState] = useState<"checking" | "allowed" | "denied" | "error">("checking");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState("checking");
|
||||
setMessage("");
|
||||
probe()
|
||||
.then(() => { if (!cancelled) setState("allowed"); })
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
if (isApiError(error, 403, 404)) {
|
||||
setState("denied");
|
||||
return;
|
||||
}
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
setState("error");
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [probe, resetKey]);
|
||||
|
||||
if (state === "denied") return <Navigate to={fallback} replace />;
|
||||
if (state === "error") return <div className="content-pad"><DismissibleAlert tone="danger" dismissible={false}>{message || "The resource could not be loaded."}</DismissibleAlert></div>;
|
||||
if (state === "checking") return <div className="content-pad"><p className="muted">Checking access…</p></div>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
5
webui/src/components/Button.tsx
Normal file
5
webui/src/components/Button.tsx
Normal 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} />;
|
||||
}
|
||||
44
webui/src/components/Card.tsx
Normal file
44
webui/src/components/Card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
type CardProps = {
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
};
|
||||
|
||||
export default function Card({ title, children, actions, collapsible = false }: CardProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const hasHeader = Boolean(title || actions || collapsible);
|
||||
const body = <div className="card-body">{children}</div>;
|
||||
const shouldRenderBody = !collapsible || !collapsed;
|
||||
|
||||
return (
|
||||
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
|
||||
{hasHeader && (
|
||||
<header className="card-header">
|
||||
{title && (typeof title === "string" ? <h2>{title}</h2> : <div className="card-title-node">{title}</div>)}
|
||||
{(actions || collapsible) && (
|
||||
<div className="card-actions">
|
||||
{actions}
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-label={collapsed ? "Show content" : "Show header only"}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Show content" : "Show header only"}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}</div> : body)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
50
webui/src/components/ConfirmDialog.tsx
Normal file
50
webui/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Button from "./Button";
|
||||
import Dialog from "./Dialog";
|
||||
|
||||
export type ConfirmDialogTone = "default" | "danger";
|
||||
|
||||
export type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
tone?: ConfirmDialogTone;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
tone = "default",
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={title}
|
||||
role="alertdialog"
|
||||
className="confirm-dialog"
|
||||
footerClassName="button-row compact-actions"
|
||||
closeOnBackdrop={!busy}
|
||||
closeDisabled={busy}
|
||||
showCloseButton
|
||||
onClose={onCancel}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
|
||||
<Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? "Working…" : confirmLabel}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p>{message}</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
96
webui/src/components/Dialog.tsx
Normal file
96
webui/src/components/Dialog.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useId, type ReactNode } from "react";
|
||||
|
||||
export type DialogProps = {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
role?: "dialog" | "alertdialog";
|
||||
ariaDescribedBy?: string;
|
||||
closeLabel?: string;
|
||||
closeOnBackdrop?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
closeDisabled?: boolean;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
backdropClassName?: string;
|
||||
headerClassName?: string;
|
||||
titleClassName?: string;
|
||||
bodyClassName?: string;
|
||||
footerClassName?: string;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function Dialog({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
role = "dialog",
|
||||
ariaDescribedBy,
|
||||
closeLabel = "Close",
|
||||
closeOnBackdrop = true,
|
||||
showCloseButton = true,
|
||||
closeDisabled = false,
|
||||
onClose,
|
||||
className = "",
|
||||
backdropClassName = "",
|
||||
headerClassName = "",
|
||||
titleClassName = "",
|
||||
bodyClassName = "",
|
||||
footerClassName = ""
|
||||
}: DialogProps) {
|
||||
const titleId = useId();
|
||||
const canClose = Boolean(onClose) && !closeDisabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canClose) return undefined;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose?.();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [canClose, onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={joinClasses("dialog-backdrop", backdropClassName)}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (closeOnBackdrop && canClose && event.target === event.currentTarget) onClose?.();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className={joinClasses("dialog-panel", className)}
|
||||
role={role}
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
<div className={joinClasses("dialog-header", headerClassName)}>
|
||||
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{title}</h2>
|
||||
{showCloseButton && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-close"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
disabled={closeDisabled}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={joinClasses("dialog-body", bodyClassName)}>{children}</div>
|
||||
{footer && <div className={joinClasses("dialog-footer", footerClassName)}>{footer}</div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
webui/src/components/DismissibleAlert.tsx
Normal file
73
webui/src/components/DismissibleAlert.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type AlertTone = "success" | "info" | "warning" | "danger";
|
||||
|
||||
type DismissibleAlertProps = {
|
||||
tone?: AlertTone;
|
||||
children: ReactNode;
|
||||
dismissible?: boolean;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
floating?: boolean;
|
||||
resetKey?: string | number;
|
||||
};
|
||||
|
||||
let floatingAlertRoot: HTMLElement | null = null;
|
||||
|
||||
function getFloatingAlertRoot(): HTMLElement | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
if (floatingAlertRoot?.isConnected) return floatingAlertRoot;
|
||||
|
||||
const existing = document.getElementById("app-floating-alerts");
|
||||
if (existing) {
|
||||
floatingAlertRoot = existing;
|
||||
return floatingAlertRoot;
|
||||
}
|
||||
|
||||
floatingAlertRoot = document.createElement("div");
|
||||
floatingAlertRoot.id = "app-floating-alerts";
|
||||
floatingAlertRoot.className = "alert-floating-stack";
|
||||
floatingAlertRoot.setAttribute("aria-label", "Application notices");
|
||||
document.body.appendChild(floatingAlertRoot);
|
||||
return floatingAlertRoot;
|
||||
}
|
||||
|
||||
export default function DismissibleAlert({
|
||||
tone = "info",
|
||||
children,
|
||||
dismissible = true,
|
||||
className = "",
|
||||
compact = false,
|
||||
floating = false,
|
||||
resetKey
|
||||
}: DismissibleAlertProps) {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(true);
|
||||
}, [resetKey, children]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const role = tone === "danger" || tone === "warning" ? "alert" : "status";
|
||||
const alert = (
|
||||
<div
|
||||
className={`alert ${tone}${compact ? " compact-alert" : ""}${floating ? " alert-floating" : ""} alert-dismissible ${className}`.trim()}
|
||||
role={role}
|
||||
aria-live={role === "alert" ? "assertive" : "polite"}
|
||||
>
|
||||
<div className="alert-message">{children}</div>
|
||||
{dismissible && (
|
||||
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={() => setVisible(false)}>
|
||||
<X size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!floating) return alert;
|
||||
const root = getFloatingAlertRoot();
|
||||
return root ? createPortal(alert, root) : alert;
|
||||
}
|
||||
12
webui/src/components/FormField.tsx
Normal file
12
webui/src/components/FormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
webui/src/components/LoadingFrame.tsx
Normal file
26
webui/src/components/LoadingFrame.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type LoadingFrameProps = {
|
||||
children: React.ReactNode;
|
||||
loading?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function LoadingFrame({ children, loading = false, label = "Loading data…", className = "" }: LoadingFrameProps) {
|
||||
const classNames = ["loading-frame", loading ? "is-loading" : "", className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className={classNames} aria-busy={loading || undefined}>
|
||||
{children}
|
||||
{loading && (
|
||||
<div className="loading-frame-overlay" role="status" aria-live="polite">
|
||||
<div className="loading-frame-panel">
|
||||
<LoadingIndicator label={label} size="md" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
webui/src/components/LoadingIndicator.tsx
Normal file
12
webui/src/components/LoadingIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
webui/src/components/MetricCard.tsx
Normal file
9
webui/src/components/MetricCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
webui/src/components/PageTitle.tsx
Normal file
15
webui/src/components/PageTitle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
webui/src/components/StatusBadge.tsx
Normal file
3
webui/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{label ?? status}</span>;
|
||||
}
|
||||
19
webui/src/components/Stepper.tsx
Normal file
19
webui/src/components/Stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
webui/src/components/ToggleSwitch.tsx
Normal file
29
webui/src/components/ToggleSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
17
webui/src/components/help/FieldLabel.tsx
Normal file
17
webui/src/components/help/FieldLabel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
webui/src/components/help/InlineHelp.tsx
Normal file
186
webui/src/components/help/InlineHelp.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1648
webui/src/components/table/DataGrid.tsx
Normal file
1648
webui/src/components/table/DataGrid.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user