first wokring prototype
This commit is contained in:
5
src/components/Button.tsx
Normal file
5
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} />;
|
||||
}
|
||||
19
src/components/Card.tsx
Normal file
19
src/components/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/FormField.tsx
Normal file
12
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>
|
||||
);
|
||||
}
|
||||
12
src/components/LoadingIndicator.tsx
Normal file
12
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
src/components/MetricCard.tsx
Normal file
9
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
src/components/PageTitle.tsx
Normal file
15
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
src/components/StatusBadge.tsx
Normal file
3
src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{status}</span>;
|
||||
}
|
||||
19
src/components/Stepper.tsx
Normal file
19
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
src/components/ToggleSwitch.tsx
Normal file
29
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
src/components/email/EmailAddressInput.tsx
Normal file
246
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
src/components/help/FieldLabel.tsx
Normal file
17
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
src/components/help/InlineHelp.tsx
Normal file
186
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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user