Refactor proxy routing and installer flows

This commit is contained in:
2026-07-08 11:05:52 +03:00
parent e745633d91
commit 88d5b94133
14 changed files with 1899 additions and 487 deletions
+209
View File
@@ -0,0 +1,209 @@
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type MouseEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
export type DetailsPopoverAlign = 'start' | 'center' | 'end';
export interface DetailsPopoverProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
details: string | string[];
children: ReactNode;
popoverLabel?: string;
align?: DetailsPopoverAlign;
maxWidth?: number;
}
interface DetailsPopoverPosition {
top: number;
left: number;
width: number;
arrowLeft: number;
placement: 'top' | 'bottom';
}
const VIEWPORT_MARGIN = 12;
export function DetailsPopover({
details,
children,
className,
popoverLabel = 'Детали',
align = 'start',
maxWidth = 360,
disabled,
onClick,
...props
}: DetailsPopoverProps) {
const detailsId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<DetailsPopoverPosition>({
top: 0,
left: 0,
width: Math.min(maxWidth, 360),
arrowLeft: 24,
placement: 'bottom',
});
const detailLines = Array.isArray(details)
? details.filter(Boolean)
: [details].filter(Boolean);
const classes = ['ui-details-popover-trigger', className ?? ''].filter(Boolean).join(' ');
useEffect(() => {
if (disabled && open) setOpen(false);
}, [disabled, open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = Math.min(maxWidth, Math.max(220, window.innerWidth - VIEWPORT_MARGIN * 2));
let left = rect.left;
if (align === 'center') left = rect.left + rect.width / 2 - width / 2;
if (align === 'end') left = rect.right - width;
left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN));
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
let top = rect.bottom + 8;
let placement: DetailsPopoverPosition['placement'] = 'bottom';
if (
popoverHeight
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
&& rect.top > popoverHeight + VIEWPORT_MARGIN + 8
) {
top = rect.top - popoverHeight - 8;
placement = 'top';
} else if (popoverHeight) {
top = Math.min(top, window.innerHeight - popoverHeight - VIEWPORT_MARGIN);
}
const arrowLeft = Math.max(
16,
Math.min(rect.left + rect.width / 2 - left, width - 16),
);
setPosition({
top: Math.max(VIEWPORT_MARGIN, top),
left,
width,
arrowLeft,
placement,
});
};
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [align, maxWidth, open]);
useEffect(() => {
if (!open) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target)) return;
if (popoverRef.current?.contains(target)) return;
setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
setOpen(false);
triggerRef.current?.focus();
};
document.addEventListener('pointerdown', closeOnOutsidePointer);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('pointerdown', closeOnOutsidePointer);
document.removeEventListener('keydown', closeOnEscape);
};
}, [open]);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) setOpen((current) => !current);
};
const popoverStyle = {
top: position.top,
left: position.left,
width: position.width,
'--details-popover-arrow-left': `${position.arrowLeft}px`,
} as CSSProperties;
return (
<>
<button
{...props}
ref={triggerRef}
type={props.type ?? 'button'}
className={classes}
aria-controls={open ? detailsId : undefined}
aria-expanded={open}
aria-haspopup="dialog"
disabled={disabled}
onClick={handleClick}
>
{children}
</button>
{open && detailLines.length && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
id={detailsId}
className="ui-details-popover"
data-placement={position.placement}
role="dialog"
aria-label={popoverLabel}
style={popoverStyle}
>
<div className="ui-details-popover-head">
<strong>{popoverLabel}</strong>
<button
type="button"
className="ui-details-popover-close"
aria-label="Закрыть"
onClick={() => {
setOpen(false);
triggerRef.current?.focus();
}}
>
×
</button>
</div>
<div className="ui-details-popover-body">
{detailLines.map((line, index) => (
<p key={`${line}-${index}`}>{line}</p>
))}
</div>
</div>,
document.body,
)
: null}
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { HTMLAttributes, ReactNode } from 'react';
export interface HoverDetailsProps extends HTMLAttributes<HTMLSpanElement> {
details: string | string[];
children: ReactNode;
}
export function HoverDetails({
details,
children,
className,
...props
}: HoverDetailsProps) {
const detailText = Array.isArray(details)
? details.filter(Boolean).join('\n')
: details;
const classes = ['ui-hover-details', className ?? ''].filter(Boolean).join(' ');
return (
<span
{...props}
className={classes}
data-tooltip={detailText}
tabIndex={props.tabIndex ?? 0}
>
{children}
</span>
);
}
+5 -4
View File
@@ -2,11 +2,12 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react';
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
label: string;
icon: ReactNode;
variant?: IconButtonVariant;
loading?: boolean;
tooltip?: string;
}
export function IconButton({
@@ -14,12 +15,12 @@ export function IconButton({
icon,
variant = 'neutral',
loading = false,
tooltip,
className,
disabled,
title,
...props
}: IconButtonProps) {
const tooltip = title === '' ? undefined : title ?? label;
const tooltipText = tooltip === '' ? undefined : tooltip ?? label;
const classes = [
'ui-icon-button',
`ui-icon-button--${variant}`,
@@ -33,7 +34,7 @@ export function IconButton({
type={props.type ?? 'button'}
className={classes}
aria-label={label}
data-tooltip={tooltip}
data-tooltip={tooltipText}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}
+4
View File
@@ -2,8 +2,12 @@ export { ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu';
export { Button } from './Button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
export { DetailsPopover } from './DetailsPopover';
export type { DetailsPopoverAlign, DetailsPopoverProps } from './DetailsPopover';
export { Field } from './Field';
export type { FieldProps } from './Field';
export { HoverDetails } from './HoverDetails';
export type { HoverDetailsProps } from './HoverDetails';
export { IconButton } from './IconButton';
export type { IconButtonProps, IconButtonVariant } from './IconButton';
export { LogDock } from './LogDock';