Refactor proxy routing and installer flows
This commit is contained in:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user