Add admin restart flow and loading indicators
This commit is contained in:
+132
-5
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user