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

View File

@@ -30,6 +30,13 @@ export interface StatusResponse {
generatedConfigPath: string;
}
export interface AdminStatusResponse {
isWindows: boolean;
isElevated: boolean;
canRestartElevated: boolean;
message: string;
}
export interface SavedStateResponse {
profiles: Profile[];
targets: Target[];
@@ -135,6 +142,14 @@ export function getStatus(): Promise<StatusResponse> {
return invoke<StatusResponse>('get_status');
}
export function getAdminStatus(): Promise<AdminStatusResponse> {
return invoke<AdminStatusResponse>('get_admin_status');
}
export function restartAsAdmin(): Promise<void> {
return invoke<void>('restart_as_admin');
}
export function getSavedState(): Promise<SavedStateResponse> {
return invoke<SavedStateResponse>('get_saved_state');
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Power, Trash2, Wand2 } from 'lucide-react';
import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Power, ShieldAlert, Trash2, Wand2 } from 'lucide-react';
import {
applyProfiles,
fetchSingBoxSubscription,
forgetSingBoxSubscription,
generateSingBoxConfig,
getAdminStatus,
getComponents,
getProxiFyreSetupStatus,
getSavedState,
@@ -16,6 +17,7 @@ import {
pingAllSingBoxServers,
pingProxyTarget,
pingSingBoxServer,
restartAsAdmin,
saveProfile,
saveSingBoxSubscription,
saveTarget,
@@ -26,6 +28,7 @@ import {
stopSingBoxService,
uninstallProxiFyre,
uninstallSingBox,
type AdminStatusResponse,
type ApplyProfilesResponse,
type LocalSingBoxStatusResponse,
type PingServerResponse,
@@ -35,7 +38,7 @@ import {
type SingBoxSetupStatus,
} from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
import { Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
import { getApplyReadiness } from './readiness';
import { serviceControlState } from './viewModel';
@@ -180,6 +183,7 @@ export function App() {
const [subscriptionInput, setSubscriptionInput] = useState('');
const [serverPings, setServerPings] = useState<Record<string, PingServerResponse>>({});
const [proxyCheck, setProxyCheck] = useState<ProxyTargetCheckResponse | null>(null);
const [adminStatus, setAdminStatus] = useState<AdminStatusResponse | null>(null);
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [activeLogId, setActiveLogId] = useState<string | null>(null);
@@ -187,6 +191,7 @@ export function App() {
const [isLoading, setIsLoading] = useState(true);
const [isDetectingComponents, setIsDetectingComponents] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [isRestartingAsAdmin, setIsRestartingAsAdmin] = useState(false);
const [isProxyChecking, setIsProxyChecking] = useState(false);
const [serverPingTag, setServerPingTag] = useState<string | null>(null);
const [serviceAction, setServiceAction] = useState<ProxiFyreAction | null>(null);
@@ -259,6 +264,7 @@ export function App() {
}, [activeLogId]);
async function refresh() {
void refreshAdminStatus();
setIsLoading(true);
try {
const saved = await getSavedState();
@@ -276,6 +282,30 @@ export function App() {
void refreshComponents();
}
async function refreshAdminStatus() {
try {
const status = await getAdminStatus();
setAdminStatus(status);
} catch {
setAdminStatus(null);
}
}
async function restartApplicationAsAdmin() {
setIsRestartingAsAdmin(true);
try {
await restartAsAdmin();
setIsRestartingAsAdmin(false);
} catch (error) {
setIsRestartingAsAdmin(false);
showNotice({
kind: 'error',
title: 'Перезапуск отменен',
text: errorMessage(error),
});
}
}
async function refreshComponents() {
setIsDetectingComponents(true);
try {
@@ -933,6 +963,33 @@ export function App() {
setActiveLogId(entry.id);
}
function renderAdminPrompt() {
if (!adminStatus?.canRestartElevated) return null;
return (
<aside className="admin-prompt" aria-label="Права администратора">
<span className="admin-prompt-icon" aria-hidden="true">
<ShieldAlert size={18} strokeWidth={1.9} />
</span>
<div className="admin-prompt-copy">
<strong>Один запрос прав вместо серии UAC</strong>
<span>{adminStatus.message}</span>
</div>
<Button
type="button"
variant="primary"
size="sm"
className="admin-prompt-action"
onClick={() => void restartApplicationAsAdmin()}
loading={isRestartingAsAdmin}
loadingLabel="Открываю UAC"
>
Перезапустить с правами
</Button>
</aside>
);
}
function renderTabs() {
const tabs: Array<{ id: PanelId; label: string }> = [
{ id: 'summary', label: 'Сводка' },
@@ -1176,6 +1233,7 @@ export function App() {
return (
<div className={`summary-status-control ${systemSummary.tone} ${running ? 'on' : 'off'}`}>
{systemSummary.tone === 'checking' ? <BusyRing /> : null}
<span className={`summary-status-dot ${systemSummary.tone}`} aria-hidden="true" />
<div className="summary-status-copy">
<span>Состояние маршрута</span>
@@ -1504,6 +1562,7 @@ export function App() {
return (
<section className={`proxy-overview ${check.tone}`} aria-label="Состояние прокси">
{check.tone === 'checking' ? <BusyRing /> : null}
{renderConnectionCheck(check)}
</section>
);
@@ -1607,6 +1666,7 @@ export function App() {
</header>
{renderTabs()}
{renderAdminPrompt()}
<div className={`tab-panel-frame swipe-${tabTransitionDirection}`} key={activePanel}>
{renderActivePanel()}
</div>

View File

@@ -10,6 +10,10 @@
--motion-panel: 220ms;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-standard: cubic-bezier(0.22, 0.72, 0.18, 1);
--busy-ring-thickness: 2px;
--busy-ring-speed: 1.6s;
--busy-ring-long: 108px;
--busy-ring-short: 64px;
--surface-canvas: #101216;
--surface-panel: #131720;
--surface-raised: #151923;
@@ -91,6 +95,8 @@ button:disabled {
.ui-button,
.ui-icon-button {
appearance: none;
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -102,6 +108,7 @@ button:disabled {
cursor: pointer;
font-weight: 750;
line-height: 1;
overflow: visible;
text-decoration: none;
transition:
background-color var(--motion-fast) var(--ease-out),
@@ -140,6 +147,18 @@ button:disabled {
transform: none;
}
.ui-button.is-loading:disabled,
.ui-icon-button.is-loading:disabled {
cursor: progress;
opacity: 1;
}
.ui-button > :not(.ui-busy-ring),
.ui-icon-button > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
.ui-button--sm {
min-height: 32px;
padding: 6px 10px;
@@ -313,13 +332,58 @@ button:disabled {
min-width: 0;
}
.ui-button-spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 999px;
animation: spin 0.75s linear infinite;
.ui-busy-ring {
position: absolute;
display: block;
z-index: 2;
inset: 0;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
}
.ui-busy-ring-segment {
position: absolute;
display: block;
background: #93c5fd;
box-shadow:
0 0 8px rgba(96, 165, 250, 0.95),
0 0 16px rgba(34, 197, 94, 0.36);
opacity: 0;
}
.ui-busy-ring-segment.top,
.ui-busy-ring-segment.bottom {
width: var(--busy-ring-long);
height: var(--busy-ring-thickness);
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.ui-busy-ring-segment.right,
.ui-busy-ring-segment.left {
width: var(--busy-ring-thickness);
height: var(--busy-ring-short);
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.ui-busy-ring-segment.top {
top: 0;
animation: finder-border-top var(--busy-ring-speed) linear infinite;
}
.ui-busy-ring-segment.right {
right: 0;
animation: finder-border-right var(--busy-ring-speed) linear infinite;
}
.ui-busy-ring-segment.bottom {
bottom: 0;
animation: finder-border-bottom var(--busy-ring-speed) linear infinite;
}
.ui-busy-ring-segment.left {
left: 0;
animation: finder-border-left var(--busy-ring-speed) linear infinite;
}
.ui-tabs {
@@ -387,6 +451,8 @@ button:disabled {
}
.ui-status-pill {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
width: fit-content;
@@ -399,6 +465,11 @@ button:disabled {
font-weight: 750;
}
.ui-status-pill > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
.ui-status-pill--ok {
border-color: rgba(34, 197, 94, 0.38);
color: #86efac;
@@ -466,10 +537,8 @@ button:disabled {
}
.ui-action-menu-popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 8;
position: fixed;
z-index: 80;
min-width: 172px;
border: 1px solid var(--border-strong);
border-radius: 4px;
@@ -480,6 +549,10 @@ button:disabled {
animation: ui-popover-in var(--motion-standard) var(--ease-out);
}
.ui-action-menu-popover[data-placement="top"] {
transform-origin: bottom right;
}
.ui-action-menu-popover button {
width: 100%;
min-height: 34px;
@@ -563,36 +636,36 @@ button:disabled {
.ui-service-border-glow-segment.top,
.ui-service-border-glow-segment.bottom {
width: 108px;
height: 2px;
width: var(--busy-ring-long);
height: var(--busy-ring-thickness);
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.ui-service-border-glow-segment.right,
.ui-service-border-glow-segment.left {
width: 2px;
height: 64px;
width: var(--busy-ring-thickness);
height: var(--busy-ring-short);
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.ui-service-border-glow-segment.top {
top: 0;
animation: finder-border-top 1.6s linear infinite;
animation: finder-border-top var(--busy-ring-speed) linear infinite;
}
.ui-service-border-glow-segment.right {
right: 0;
animation: finder-border-right 1.6s linear infinite;
animation: finder-border-right var(--busy-ring-speed) linear infinite;
}
.ui-service-border-glow-segment.bottom {
bottom: 0;
animation: finder-border-bottom 1.6s linear infinite;
animation: finder-border-bottom var(--busy-ring-speed) linear infinite;
}
.ui-service-border-glow-segment.left {
left: 0;
animation: finder-border-left 1.6s linear infinite;
animation: finder-border-left var(--busy-ring-speed) linear infinite;
}
.ui-service-dot {
@@ -621,11 +694,8 @@ button:disabled {
}
.ui-service-row--checking .ui-service-dot {
border: 2px solid var(--focus-ring);
border-top-color: transparent;
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
background: #60a5fa;
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
}
.ui-service-text {
@@ -835,6 +905,54 @@ button:disabled {
content: "";
}
.admin-prompt {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-width: 0;
border: 1px solid rgba(245, 158, 11, 0.44);
border-radius: 4px;
background: rgba(120, 53, 15, 0.18);
margin-bottom: 10px;
padding: 8px 10px;
}
.admin-prompt-icon {
display: grid;
place-items: center;
width: 32px;
min-width: 32px;
height: 32px;
border: 1px solid rgba(245, 158, 11, 0.28);
border-radius: 4px;
background: rgba(15, 23, 42, 0.42);
color: #fcd34d;
}
.admin-prompt-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-prompt-copy strong {
color: #f8fafc;
font-size: 13px;
font-weight: 800;
}
.admin-prompt-copy span {
color: #c7b489;
font-size: 12px;
overflow-wrap: anywhere;
}
.admin-prompt-action {
min-width: 230px;
white-space: nowrap;
}
.tab-panel-frame {
min-width: 0;
will-change: opacity, transform;
@@ -887,6 +1005,8 @@ button:disabled {
}
.proxy-overview {
position: relative;
isolation: isolate;
display: block;
border: 1px solid #2b3342;
border-radius: 4px;
@@ -894,6 +1014,11 @@ button:disabled {
padding: 8px 10px;
}
.proxy-overview > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
.proxy-overview.ok {
border-color: rgba(34, 197, 94, 0.36);
}
@@ -915,6 +1040,8 @@ button:disabled {
}
.summary-status-control {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
@@ -925,6 +1052,11 @@ button:disabled {
padding: 13px 14px;
}
.summary-status-control > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
.summary-status-control.ok {
border-color: rgba(34, 197, 94, 0.44);
}
@@ -965,10 +1097,8 @@ button:disabled {
}
.summary-status-dot.checking {
border: 2px solid #3b82f6;
border-top-color: transparent;
background: transparent;
animation: spin 0.75s linear infinite;
background: #60a5fa;
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
}
.summary-status-copy span,
@@ -1070,6 +1200,8 @@ button:disabled {
}
.summary-card {
position: relative;
isolation: isolate;
display: grid;
gap: 5px;
align-content: start;
@@ -1082,6 +1214,11 @@ button:disabled {
text-align: left;
}
.summary-card > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
button.summary-card {
cursor: pointer;
}
@@ -1227,36 +1364,36 @@ button.summary-card:hover {
.finder-border-glow-segment.top,
.finder-border-glow-segment.bottom {
width: 108px;
height: 2px;
width: var(--busy-ring-long);
height: var(--busy-ring-thickness);
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.finder-border-glow-segment.right,
.finder-border-glow-segment.left {
width: 2px;
height: 64px;
width: var(--busy-ring-thickness);
height: var(--busy-ring-short);
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.finder-border-glow-segment.top {
top: 0;
animation: finder-border-top 1.6s linear infinite;
animation: finder-border-top var(--busy-ring-speed) linear infinite;
}
.finder-border-glow-segment.right {
right: 0;
animation: finder-border-right 1.6s linear infinite;
animation: finder-border-right var(--busy-ring-speed) linear infinite;
}
.finder-border-glow-segment.bottom {
bottom: 0;
animation: finder-border-bottom 1.6s linear infinite;
animation: finder-border-bottom var(--busy-ring-speed) linear infinite;
}
.finder-border-glow-segment.left {
left: 0;
animation: finder-border-left 1.6s linear infinite;
animation: finder-border-left var(--busy-ring-speed) linear infinite;
}
.finder-text,
@@ -1305,11 +1442,8 @@ button.summary-card:hover {
}
.finder-card.checking .status-light {
border: 2px solid #3b82f6;
border-top-color: transparent;
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
background: #60a5fa;
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
}
.setup-strip {
@@ -1838,6 +1972,8 @@ button.summary-card:hover {
}
.route-chain-segment {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas:
@@ -1856,6 +1992,11 @@ button.summary-card:hover {
width: 100%;
}
.route-chain-segment > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
.route-chain-segment:hover {
border-color: #3b82f6;
background: #141d2b;
@@ -1909,11 +2050,8 @@ button.summary-card:hover {
}
.route-chain-segment.checking .route-chain-dot {
border: 2px solid #3b82f6;
border-top-color: transparent;
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
background: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.14);
}
.apps-section {
@@ -2196,9 +2334,17 @@ button.summary-card:hover {
}
.tile-loading {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
min-height: 30px;
border: 1px solid rgba(59, 130, 246, 0.42);
border-radius: 4px;
background: #111720;
color: #dbeafe;
font-weight: 800;
animation: pulse 1s ease-in-out infinite;
padding: 6px 9px;
}
.app-add-skeleton {
@@ -2327,14 +2473,12 @@ button.summary-card:hover {
}
.list-skeleton span {
position: relative;
isolation: isolate;
min-height: 56px;
border: 1px solid #2b3342;
border-radius: 4px;
background:
linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.12), transparent),
#131720;
background-size: 220% 100%;
animation: shimmer 1.15s linear infinite;
background: #131720;
}
.subscription-line .ui-button {
@@ -2514,18 +2658,6 @@ button.summary-card:hover {
color: #b6c2d4;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
50% {
opacity: 0.45;
}
}
@keyframes finder-border-top {
0% {
left: -116px;
@@ -2600,16 +2732,6 @@ button.summary-card:hover {
}
}
@keyframes shimmer {
from {
background-position: 220% 0;
}
to {
background-position: -220% 0;
}
}
@keyframes panel-swipe-right {
from {
opacity: 0;
@@ -2637,12 +2759,14 @@ button.summary-card:hover {
@media (prefers-reduced-motion: reduce) {
.tab-panel-frame,
.tab-panel,
.ui-busy-ring,
.ui-busy-ring-segment,
.ui-service-border-glow,
.finder-card .finder-border-glow,
.finder-border-glow-segment,
.ui-service-border-glow-segment,
.status-light,
.summary-status-dot.checking,
.list-skeleton span,
.tile-loading {
.summary-status-dot.checking {
animation: none;
transition: none;
}
@@ -2662,6 +2786,18 @@ button.summary-card:hover {
padding: 12px 12px 16px;
}
.admin-prompt {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 8px;
}
.admin-prompt-action {
grid-column: 1 / -1;
width: 100%;
min-width: 0;
}
.app-row {
align-items: stretch;
flex-direction: column;

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
src/ui/BusyRing.tsx Normal file
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>
);
}

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>

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>
);
}

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>
);
}

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';