intermittent commit
This commit is contained in:
103
webui/src/components/CredentialPanel.tsx
Normal file
103
webui/src/components/CredentialPanel.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { ReactNode } from "react";
|
||||
import FormField from "./FormField";
|
||||
import PasswordField from "./PasswordField";
|
||||
|
||||
export type CredentialValues = {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type CredentialFieldsProps = {
|
||||
values: CredentialValues;
|
||||
onChange: (patch: Partial<CredentialValues>) => void;
|
||||
disabled?: boolean;
|
||||
usernameDisabled?: boolean;
|
||||
passwordDisabled?: boolean;
|
||||
savedPassword?: boolean;
|
||||
savedPasswordPlaceholder?: string;
|
||||
heading?: ReactNode;
|
||||
headingClassName?: string;
|
||||
usernameLabel?: ReactNode;
|
||||
passwordLabel?: ReactNode;
|
||||
usernamePlaceholder?: string;
|
||||
passwordPlaceholder?: string;
|
||||
usernameAutoComplete?: string;
|
||||
passwordAutoComplete?: string;
|
||||
showUsername?: boolean;
|
||||
showPassword?: boolean;
|
||||
};
|
||||
|
||||
export type CredentialPanelProps = CredentialFieldsProps & {
|
||||
className?: string;
|
||||
gridClassName?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function CredentialFields({
|
||||
values,
|
||||
onChange,
|
||||
disabled = false,
|
||||
usernameDisabled = disabled,
|
||||
passwordDisabled = disabled,
|
||||
savedPassword = false,
|
||||
savedPasswordPlaceholder = "••••••••",
|
||||
heading,
|
||||
headingClassName = "credential-panel-heading",
|
||||
usernameLabel = "i18n:govoplan-core.username.84c29015",
|
||||
passwordLabel = "i18n:govoplan-core.password.8be3c943",
|
||||
usernamePlaceholder,
|
||||
passwordPlaceholder,
|
||||
usernameAutoComplete = "username",
|
||||
passwordAutoComplete = "new-password",
|
||||
showUsername = true,
|
||||
showPassword = true
|
||||
}: CredentialFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
{heading && <div className={headingClassName}>{heading}</div>}
|
||||
{showUsername &&
|
||||
<FormField label={usernameLabel}>
|
||||
<input
|
||||
value={stringValue(values.username)}
|
||||
disabled={usernameDisabled}
|
||||
autoComplete={usernameAutoComplete}
|
||||
placeholder={usernamePlaceholder}
|
||||
onChange={(event) => onChange({ username: event.target.value })} />
|
||||
</FormField>
|
||||
}
|
||||
{showPassword &&
|
||||
<FormField label={passwordLabel}>
|
||||
<PasswordField
|
||||
value={stringValue(values.password)}
|
||||
disabled={passwordDisabled}
|
||||
saved={savedPassword}
|
||||
savedPlaceholder={savedPasswordPlaceholder}
|
||||
placeholder={passwordPlaceholder}
|
||||
autoComplete={passwordAutoComplete}
|
||||
onValueChange={(password) => onChange({ password })} />
|
||||
</FormField>
|
||||
}
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
export default function CredentialPanel({
|
||||
className = "",
|
||||
gridClassName = "",
|
||||
children,
|
||||
...fieldProps
|
||||
}: CredentialPanelProps) {
|
||||
return (
|
||||
<div className={`credential-panel ${className}`.trim()}>
|
||||
<div className={`credential-panel-grid ${gridClassName}`.trim()}>
|
||||
<CredentialFields {...fieldProps} />
|
||||
</div>
|
||||
{children && <div className="credential-panel-extra">{children}</div>}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function stringValue(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
return String(value);
|
||||
}
|
||||
21
webui/src/components/DisabledActionTooltip.tsx
Normal file
21
webui/src/components/DisabledActionTooltip.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
import HoverTooltip from "./HoverTooltip";
|
||||
|
||||
export type DisabledActionTooltipProps = {
|
||||
reason?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function DisabledActionTooltip({ reason, children, className = "" }: DisabledActionTooltipProps) {
|
||||
if (!reason) return <>{children}</>;
|
||||
return (
|
||||
<HoverTooltip
|
||||
content={reason}
|
||||
tone="danger"
|
||||
className={`disabled-action-tooltip ${className}`.trim()}
|
||||
triggerTabIndex={0}>
|
||||
{children}
|
||||
</HoverTooltip>
|
||||
);
|
||||
}
|
||||
228
webui/src/components/HoverTooltip.tsx
Normal file
228
webui/src/components/HoverTooltip.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type HoverTooltipTone = "default" | "danger";
|
||||
|
||||
type TooltipPosition = {
|
||||
top: number;
|
||||
left: number;
|
||||
arrowLeft: number;
|
||||
placement: "top" | "bottom";
|
||||
};
|
||||
|
||||
export type HoverTooltipProps = {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
tone?: HoverTooltipTone;
|
||||
ariaLabel?: string;
|
||||
triggerTabIndex?: number;
|
||||
openDelayMs?: number;
|
||||
};
|
||||
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
const TRIGGER_GAP = 10;
|
||||
|
||||
const BASE_TOOLTIP_STYLE: CSSProperties = {
|
||||
position: "fixed",
|
||||
zIndex: 20000,
|
||||
width: "max-content",
|
||||
maxWidth: "min(320px, calc(100vw - 48px))",
|
||||
borderRadius: 7,
|
||||
boxShadow: "var(--shadow-popover)",
|
||||
fontSize: 12,
|
||||
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);
|
||||
}
|
||||
|
||||
function tooltipStyleForTone(tone: HoverTooltipTone): CSSProperties {
|
||||
if (tone === "danger") {
|
||||
return {
|
||||
border: "1px solid var(--border-danger-soft)",
|
||||
background: "var(--danger-muted-bg)",
|
||||
color: "var(--danger-text-tooltip)",
|
||||
fontWeight: 700
|
||||
};
|
||||
}
|
||||
return {
|
||||
border: "1px solid var(--line-dark)",
|
||||
background: "var(--surface)",
|
||||
color: "var(--text)",
|
||||
fontWeight: 500
|
||||
};
|
||||
}
|
||||
|
||||
function arrowStyleForTone(tone: HoverTooltipTone): Pick<CSSProperties, "borderColor" | "background"> {
|
||||
if (tone === "danger") {
|
||||
return {
|
||||
borderColor: "var(--border-danger-soft)",
|
||||
background: "var(--danger-muted-bg)"
|
||||
};
|
||||
}
|
||||
return {
|
||||
borderColor: "var(--line-dark)",
|
||||
background: "var(--surface)"
|
||||
};
|
||||
}
|
||||
|
||||
export default function HoverTooltip({
|
||||
content,
|
||||
children,
|
||||
className = "",
|
||||
tone = "default",
|
||||
ariaLabel,
|
||||
triggerTabIndex,
|
||||
openDelayMs = 350
|
||||
}: HoverTooltipProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
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(() => {
|
||||
if (!content) return;
|
||||
clearOpenTimer();
|
||||
openTimerRef.current = window.setTimeout(() => {
|
||||
openTimerRef.current = null;
|
||||
setIsOpen(true);
|
||||
}, openDelayMs);
|
||||
}, [clearOpenTimer, content, openDelayMs]);
|
||||
|
||||
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]);
|
||||
|
||||
const translatedAriaLabel = ariaLabel ? translateText(ariaLabel) : undefined;
|
||||
const tooltipStyle: CSSProperties = {
|
||||
...BASE_TOOLTIP_STYLE,
|
||||
...tooltipStyleForTone(tone),
|
||||
top: position?.top ?? -9999,
|
||||
left: position?.left ?? -9999,
|
||||
opacity: position ? 1 : 0
|
||||
};
|
||||
const arrowColors = arrowStyleForTone(tone);
|
||||
const arrowStyle: CSSProperties = position?.placement === "bottom"
|
||||
? {
|
||||
position: "absolute",
|
||||
left: position.arrowLeft,
|
||||
top: 0,
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderLeft: "1px solid",
|
||||
borderTop: "1px solid",
|
||||
...arrowColors,
|
||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||
}
|
||||
: {
|
||||
position: "absolute",
|
||||
left: position?.arrowLeft ?? 16,
|
||||
top: "100%",
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRight: "1px solid",
|
||||
borderBottom: "1px solid",
|
||||
...arrowColors,
|
||||
transform: "translate(-50%, -5px) rotate(45deg)"
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className={className}
|
||||
tabIndex={triggerTabIndex}
|
||||
aria-label={translatedAriaLabel}
|
||||
aria-describedby={isOpen ? tooltipId : undefined}
|
||||
onMouseEnter={openWithDelay}
|
||||
onMouseLeave={close}
|
||||
onFocus={openWithDelay}
|
||||
onBlur={close}>
|
||||
{children}
|
||||
</span>
|
||||
{isOpen && typeof document !== "undefined" && createPortal(
|
||||
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
|
||||
{typeof content === "string" ? translateText(content) : content}
|
||||
<span aria-hidden="true" style={arrowStyle} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { i18nMessage } from "../../i18n/LanguageContext";import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Button from "../Button";
|
||||
@@ -15,6 +16,7 @@ type EmailAddressInputProps = {
|
||||
value: MailboxAddress[];
|
||||
onChange?: (value: MailboxAddress[]) => void;
|
||||
onAddressAdded?: (address: MailboxAddress) => void;
|
||||
onSuggestionQueryChange?: (query: string) => void;
|
||||
suggestions?: MailboxAddress[];
|
||||
allowMultiple?: boolean;
|
||||
clearOnAdd?: boolean;
|
||||
@@ -31,6 +33,7 @@ export default function EmailAddressInput({
|
||||
value,
|
||||
onChange,
|
||||
onAddressAdded,
|
||||
onSuggestionQueryChange,
|
||||
suggestions = [],
|
||||
allowMultiple = true,
|
||||
clearOnAdd = false,
|
||||
@@ -42,6 +45,7 @@ export default function EmailAddressInput({
|
||||
compact = false,
|
||||
showAddButton
|
||||
}: EmailAddressInputProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const inputId = useId();
|
||||
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
|
||||
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
|
||||
@@ -51,9 +55,18 @@ export default function EmailAddressInput({
|
||||
const [dialogEmail, setDialogEmail] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
|
||||
const lastSuggestionQueryRef = useRef<string | null>(null);
|
||||
const addButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const canUseAddButton = showAddButton ?? allowMultiple;
|
||||
|
||||
useEffect(() => {
|
||||
const query = entryText.trim();
|
||||
if (query === "" && lastSuggestionQueryRef.current === null) return;
|
||||
if (query === lastSuggestionQueryRef.current) return;
|
||||
lastSuggestionQueryRef.current = query;
|
||||
onSuggestionQueryChange?.(query);
|
||||
}, [entryText, onSuggestionQueryChange]);
|
||||
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
const query = entryText.trim().toLowerCase();
|
||||
if (!query) return normalizedSuggestions.slice(0, 6);
|
||||
@@ -168,18 +181,18 @@ export default function EmailAddressInput({
|
||||
if (event.key === "Escape") setDialogOpen(false);
|
||||
}}>
|
||||
|
||||
<h4 id={`${inputId}-dialog-title`}>i18n:govoplan-core.add_address.a71075c4</h4>
|
||||
<h4 id={`${inputId}-dialog-title`}>{translateText("i18n:govoplan-core.add_address.a71075c4")}</h4>
|
||||
<label>
|
||||
<span>i18n:govoplan-core.name.709a2322</span>
|
||||
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus />
|
||||
<span>{translateText("i18n:govoplan-core.name.709a2322")}</span>
|
||||
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={translateText(namePlaceholder)} autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
<span>i18n:govoplan-core.email_address.c94d3175</span>
|
||||
<span>{translateText("i18n:govoplan-core.email_address.c94d3175")}</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)}>i18n:govoplan-core.cancel.77dfd213</Button>
|
||||
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button>
|
||||
<Button type="button" onClick={() => setDialogOpen(false)}>{translateText("i18n:govoplan-core.cancel.77dfd213")}</Button>
|
||||
<Button type="button" variant="primary" onClick={commitDialogAddress}>{translateText(addLabel)}</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
@@ -189,11 +202,11 @@ export default function EmailAddressInput({
|
||||
<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.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
|
||||
{normalizedValue.map((address) => {
|
||||
const valid = isValidEmailAddress(address.email);
|
||||
return (
|
||||
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "i18n:govoplan-core.invalid_email_address.9e4ee6d7"}>
|
||||
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : translateText("i18n:govoplan-core.invalid_email_address.9e4ee6d7")}>
|
||||
<span className="email-chip-main">{addressDisplayName(address)}</span>
|
||||
{address.name && <span className="email-chip-address">{address.email}</span>}
|
||||
{!disabled &&
|
||||
@@ -217,11 +230,11 @@ export default function EmailAddressInput({
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={handleTextKeyDown}
|
||||
placeholder={i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: namePlaceholder, value1: emailPlaceholder })}
|
||||
aria-label="i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0" />
|
||||
placeholder={translateText(i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: translateText(namePlaceholder), value1: emailPlaceholder }))}
|
||||
aria-label={translateText("i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0")} />
|
||||
|
||||
{canUseAddButton &&
|
||||
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="i18n:govoplan-core.open_address_form.f8ee560f" title="i18n:govoplan-core.add_address_with_form.13b3b3e7" onClick={() => setDialogOpen((open) => !open)}>
|
||||
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label={translateText("i18n:govoplan-core.open_address_form.f8ee560f")} title={translateText("i18n:govoplan-core.add_address_with_form.13b3b3e7")} onClick={() => setDialogOpen((open) => !open)}>
|
||||
+
|
||||
</button>
|
||||
}
|
||||
@@ -230,7 +243,7 @@ export default function EmailAddressInput({
|
||||
</div>
|
||||
|
||||
{!disabled && filteredSuggestions.length > 0 && entryText.trim() &&
|
||||
<div className="email-address-suggestions" role="listbox" aria-label="i18n:govoplan-core.address_suggestions.45ba4a20">
|
||||
<div className="email-address-suggestions" role="listbox" aria-label={translateText("i18n:govoplan-core.address_suggestions.45ba4a20")}>
|
||||
{filteredSuggestions.map((item) =>
|
||||
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
|
||||
<span>{addressDisplayName(item)}</span>
|
||||
@@ -239,7 +252,7 @@ export default function EmailAddressInput({
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
{error && <p className="form-help danger-text">{error}</p>}
|
||||
{error && <p className="form-help danger-text">{translateText(error)}</p>}
|
||||
{addressDialog}
|
||||
</div>);
|
||||
|
||||
|
||||
@@ -1,188 +1,20 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import type { ReactNode } from "react";
|
||||
import HoverTooltip from "../HoverTooltip";
|
||||
|
||||
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: 20000,
|
||||
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 { translateText } = usePlatformLanguage();
|
||||
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={-1}
|
||||
aria-label={translateText("i18n:govoplan-core.show_field_help.e3dfe98f")}
|
||||
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}>
|
||||
{typeof children === "string" ? translateText(children) : children}
|
||||
<span aria-hidden="true" style={arrowStyle} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>);
|
||||
|
||||
<HoverTooltip
|
||||
content={children}
|
||||
className={`inline-help ${className}`.trim()}
|
||||
ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f"
|
||||
triggerTabIndex={-1}>
|
||||
<span className="inline-help-mark" aria-hidden="true">?</span>
|
||||
</HoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "../Button";
|
||||
import { CredentialFields } from "../CredentialPanel";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import FormField from "../FormField";
|
||||
import PasswordField from "../PasswordField";
|
||||
import SegmentedControl from "../SegmentedControl";
|
||||
import ToggleSwitch from "../ToggleSwitch";
|
||||
|
||||
@@ -48,6 +48,9 @@ export type MailServerFolderLookupResult = {
|
||||
details?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
||||
export type MailServerSettingsMode = "all" | "server" | "credentials";
|
||||
|
||||
export type MailServerSettingsPanelProps = {
|
||||
smtp: MailServerSmtpSettings;
|
||||
imap: MailServerImapSettings;
|
||||
@@ -97,12 +100,13 @@ export type MailServerSettingsPanelProps = {
|
||||
className?: string;
|
||||
floatingResults?: boolean;
|
||||
initialSection?: MailServerSettingsSection;
|
||||
visibleSections?: readonly MailServerSettingsSection[];
|
||||
mode?: MailServerSettingsMode;
|
||||
};
|
||||
|
||||
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
|
||||
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
|
||||
const securityOptions = mailServerSecurityOptions;
|
||||
type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
||||
|
||||
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
|
||||
if (security === "tls") return 465;
|
||||
@@ -234,7 +238,9 @@ export default function MailServerSettingsPanel({
|
||||
disabled = false,
|
||||
className = "",
|
||||
floatingResults = false,
|
||||
initialSection = "smtp"
|
||||
initialSection = "smtp",
|
||||
visibleSections,
|
||||
mode = "all"
|
||||
}: MailServerSettingsPanelProps) {
|
||||
const smtpFieldsDisabled = disabled || smtpDisabled;
|
||||
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
|
||||
@@ -254,11 +260,29 @@ export default function MailServerSettingsPanel({
|
||||
const appendTargetHelp = append ?
|
||||
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
|
||||
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
|
||||
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection);
|
||||
const sections: {id: MailServerSettingsSection;label: string;}[] = [
|
||||
const allSections: {id: MailServerSettingsSection;label: string;}[] = [
|
||||
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
|
||||
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" },
|
||||
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }];
|
||||
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
|
||||
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
|
||||
const fallbackSection = sections[0]?.id ?? "smtp";
|
||||
const resolvedInitialSection = sections.some((section) => section.id === initialSection) ? initialSection : fallbackSection;
|
||||
const sectionKey = sections.map((section) => section.id).join("|");
|
||||
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
|
||||
const showSectionSwitcher = sections.length > 1;
|
||||
const showServerFields = mode !== "credentials";
|
||||
const showCredentialFields = mode !== "server";
|
||||
|
||||
useEffect(() => {
|
||||
if (!sections.some((section) => section.id === activeSection)) {
|
||||
setActiveSection(resolvedInitialSection);
|
||||
return;
|
||||
}
|
||||
if (initialSection !== activeSection && sections.some((section) => section.id === initialSection)) {
|
||||
setActiveSection(initialSection);
|
||||
}
|
||||
}, [activeSection, initialSection, resolvedInitialSection, sectionKey]);
|
||||
|
||||
|
||||
function patchSmtpSecurity(security: MailServerSecurity) {
|
||||
@@ -283,6 +307,7 @@ export default function MailServerSettingsPanel({
|
||||
|
||||
return (
|
||||
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
||||
{showSectionSwitcher &&
|
||||
<SegmentedControl
|
||||
className="mail-server-segmented-control"
|
||||
size="equal"
|
||||
@@ -291,27 +316,30 @@ export default function MailServerSettingsPanel({
|
||||
onChange={setActiveSection}
|
||||
options={sections}
|
||||
/>
|
||||
}
|
||||
|
||||
<div className="mail-server-settings-view">
|
||||
{activeSection === "smtp" &&
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
{showServerFields &&
|
||||
<>
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div>
|
||||
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
||||
<PasswordField
|
||||
value={stringValue(smtpCredentialValues.password)}
|
||||
</>
|
||||
}
|
||||
{showCredentialFields &&
|
||||
<CredentialFields
|
||||
values={smtpCredentialValues}
|
||||
onChange={patchSmtpCredentials}
|
||||
disabled={smtpCredentialFieldsDisabled}
|
||||
saved={smtpPasswordSaved}
|
||||
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchSmtpCredentials({ password })} />
|
||||
|
||||
</FormField>
|
||||
savedPassword={smtpPasswordSaved}
|
||||
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder}
|
||||
heading="i18n:govoplan-core.credentials.dd097a22"
|
||||
headingClassName="mail-server-field-heading" />
|
||||
}
|
||||
</div>
|
||||
{onTestSmtp &&
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
@@ -325,22 +353,24 @@ export default function MailServerSettingsPanel({
|
||||
{activeSection === "imap" &&
|
||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
{showServerFields &&
|
||||
<>
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
||||
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div>
|
||||
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
||||
<PasswordField
|
||||
value={stringValue(imapCredentialValues.password)}
|
||||
</>
|
||||
}
|
||||
{showCredentialFields &&
|
||||
<CredentialFields
|
||||
values={imapCredentialValues}
|
||||
onChange={patchImapCredentials}
|
||||
disabled={imapCredentialFieldsDisabled}
|
||||
saved={imapPasswordSaved}
|
||||
savedPlaceholder={imapSavedPasswordPlaceholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => patchImapCredentials({ password })} />
|
||||
|
||||
</FormField>
|
||||
savedPassword={imapPasswordSaved}
|
||||
savedPasswordPlaceholder={imapSavedPasswordPlaceholder}
|
||||
heading="i18n:govoplan-core.credentials.dd097a22"
|
||||
headingClassName="mail-server-field-heading" />
|
||||
}
|
||||
</div>
|
||||
{onTestImap &&
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
|
||||
@@ -150,7 +150,7 @@ type ColumnResizeState = {
|
||||
behavior: DataGridResizeBehavior;
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = "multimailer.datagrid.";
|
||||
const STORAGE_PREFIX = "govoplan.datagrid.";
|
||||
const FILTER_POPOVER_WIDTH = 320;
|
||||
const FILTER_POPOVER_MARGIN = 12;
|
||||
const MIN_HEADER_LABEL_WIDTH = 72;
|
||||
|
||||
Reference in New Issue
Block a user