Add admin restart flow and loading indicators

This commit is contained in:
2026-07-08 13:34:43 +03:00
parent ae13070eba
commit 838dea0e03
11 changed files with 639 additions and 127 deletions
+132 -5
View File
@@ -1,4 +1,13 @@
import { MoreHorizontal } from 'lucide-react';
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from 'react';
import { createPortal } from 'react-dom';
import { IconButton } from './IconButton';
export interface ActionMenuItem {
@@ -16,6 +25,17 @@ export interface ActionMenuProps {
disabled?: boolean;
}
interface ActionMenuPosition {
top: number;
left: number;
width: number;
placement: 'top' | 'bottom';
}
const MENU_WIDTH = 190;
const VIEWPORT_MARGIN = 12;
const MENU_OFFSET = 6;
export function ActionMenu({
open,
onOpenChange,
@@ -23,30 +43,137 @@ export function ActionMenu({
items,
disabled,
}: ActionMenuProps) {
const menuId = useId();
const triggerRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<ActionMenuPosition>({
top: 0,
left: 0,
width: MENU_WIDTH,
placement: 'bottom',
});
useEffect(() => {
if (disabled && open) onOpenChange(false);
}, [disabled, onOpenChange, open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = Math.min(MENU_WIDTH, Math.max(180, window.innerWidth - VIEWPORT_MARGIN * 2));
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
const left = Math.max(
VIEWPORT_MARGIN,
Math.min(rect.right - width, window.innerWidth - width - VIEWPORT_MARGIN),
);
let top = rect.bottom + MENU_OFFSET;
let placement: ActionMenuPosition['placement'] = 'bottom';
if (
popoverHeight
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
&& rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
) {
top = rect.top - popoverHeight - MENU_OFFSET;
placement = 'top';
}
const maxTop = popoverHeight
? window.innerHeight - popoverHeight - VIEWPORT_MARGIN
: window.innerHeight - VIEWPORT_MARGIN;
setPosition({
top: Math.max(VIEWPORT_MARGIN, Math.min(top, maxTop)),
left,
width,
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);
};
}, [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;
onOpenChange(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
onOpenChange(false);
};
document.addEventListener('pointerdown', closeOnOutsidePointer);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('pointerdown', closeOnOutsidePointer);
document.removeEventListener('keydown', closeOnEscape);
};
}, [onOpenChange, open]);
const popoverStyle = {
top: position.top,
left: position.left,
width: position.width,
} as CSSProperties;
return (
<div className="ui-action-menu">
<div className="ui-action-menu" ref={triggerRef}>
<IconButton
label={label}
icon={<MoreHorizontal size={20} strokeWidth={2} />}
onClick={() => onOpenChange(!open)}
disabled={disabled}
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-haspopup="menu"
/>
{open ? (
<div className="ui-action-menu-popover" role="menu">
{open && typeof document !== 'undefined' ? createPortal(
<div
className="ui-action-menu-popover"
data-placement={position.placement}
id={menuId}
ref={popoverRef}
role="menu"
style={popoverStyle}
>
{items.map((item) => (
<button
type="button"
role="menuitem"
className={item.danger ? 'is-danger' : ''}
onClick={item.onClick}
onClick={() => {
onOpenChange(false);
item.onClick();
}}
disabled={item.disabled}
key={item.label}
>
{item.label}
</button>
))}
</div>
</div>,
document.body,
) : null}
</div>
);
+16
View File
@@ -0,0 +1,16 @@
export interface BusyRingProps {
className?: string;
}
export function BusyRing({ className }: BusyRingProps) {
const classes = ['ui-busy-ring', className ?? ''].filter(Boolean).join(' ');
return (
<span className={classes} aria-hidden="true">
<span className="ui-busy-ring-segment top" />
<span className="ui-busy-ring-segment right" />
<span className="ui-busy-ring-segment bottom" />
<span className="ui-busy-ring-segment left" />
</span>
);
}
+4 -1
View File
@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { BusyRing } from './BusyRing';
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
@@ -37,8 +38,10 @@ export function Button({
{...props}
className={classes}
disabled={disabled || loading}
aria-busy={loading || undefined}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : leftIcon ? (
{loading ? <BusyRing /> : null}
{!loading && leftIcon ? (
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
) : null}
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
+4 -1
View File
@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { BusyRing } from './BusyRing';
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
@@ -36,8 +37,10 @@ export function IconButton({
aria-label={label}
data-tooltip={tooltipText}
disabled={disabled || loading}
aria-busy={loading || undefined}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}
{loading ? <BusyRing /> : null}
{icon}
</button>
);
}
+11 -1
View File
@@ -1,3 +1,5 @@
import { BusyRing } from './BusyRing';
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
export interface StatusPillProps {
@@ -6,6 +8,14 @@ export interface StatusPillProps {
}
export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
return <span className={`ui-status-pill ui-status-pill--${tone}`}>{children}</span>;
return (
<span
className={`ui-status-pill ui-status-pill--${tone}`}
aria-busy={tone === 'checking' || undefined}
>
{tone === 'checking' ? <BusyRing /> : null}
{children}
</span>
);
}
+2
View File
@@ -1,5 +1,7 @@
export { ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu';
export { BusyRing } from './BusyRing';
export type { BusyRingProps } from './BusyRing';
export { Button } from './Button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
export { DetailsPopover } from './DetailsPopover';