Add admin restart flow and loading indicators
This commit is contained in:
@@ -149,6 +149,15 @@ impl CommandError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminStatusResponse {
|
||||
pub is_windows: bool,
|
||||
pub is_elevated: bool,
|
||||
pub can_restart_elevated: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ValidationIssue {
|
||||
@@ -542,6 +551,18 @@ pub async fn get_status(
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_admin_status() -> AdminStatusResponse {
|
||||
admin_status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restart_as_admin(app: tauri::AppHandle) -> Result<(), CommandError> {
|
||||
launch_app_as_admin()?;
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_saved_state(
|
||||
state: tauri::State<'_, CommandState>,
|
||||
@@ -814,6 +835,87 @@ pub async fn uninstall_singbox() -> Result<ComponentStatusDto, CommandError> {
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
|
||||
pub fn admin_status() -> AdminStatusResponse {
|
||||
let is_windows = cfg!(windows);
|
||||
let is_elevated = is_running_elevated();
|
||||
let message = if !is_windows {
|
||||
"Проверка прав администратора нужна только в Windows.".to_string()
|
||||
} else if is_elevated {
|
||||
"ProxyWarden уже запущен от имени администратора.".to_string()
|
||||
} else {
|
||||
"Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string()
|
||||
};
|
||||
|
||||
AdminStatusResponse {
|
||||
is_windows,
|
||||
is_elevated,
|
||||
can_restart_elevated: is_windows && !is_elevated,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_app_as_admin() -> Result<(), CommandError> {
|
||||
if !cfg!(windows) {
|
||||
return Err(CommandError::new(
|
||||
"admin_restart_unsupported",
|
||||
"Перезапуск от имени администратора доступен только в Windows.",
|
||||
));
|
||||
}
|
||||
|
||||
if is_running_elevated() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().map_err(|error| {
|
||||
CommandError::new(
|
||||
"admin_restart_failed",
|
||||
format!("Не удалось определить путь текущего приложения: {error}"),
|
||||
)
|
||||
})?;
|
||||
let working_dir = env::current_dir().ok();
|
||||
let working_dir_arg = working_dir
|
||||
.as_ref()
|
||||
.map(|path| {
|
||||
format!(
|
||||
" -WorkingDirectory '{}'",
|
||||
escape_powershell_single(&path.display().to_string())
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let script = format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try {{
|
||||
Start-Process -FilePath '{}' -Verb RunAs{}
|
||||
exit 0
|
||||
}} catch {{
|
||||
Write-Error ($_ | Out-String)
|
||||
exit 1
|
||||
}}
|
||||
"#,
|
||||
escape_powershell_single(&exe_path.display().to_string()),
|
||||
working_dir_arg
|
||||
);
|
||||
let output = run_powershell_command(&script).map_err(|error| {
|
||||
CommandError::new(
|
||||
"admin_restart_failed",
|
||||
format!("Не удалось запросить права администратора: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(CommandError::new(
|
||||
"admin_restart_failed",
|
||||
powershell_output_message(
|
||||
&output,
|
||||
"Перезапуск от имени администратора отменен или не был запущен.",
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> {
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
@@ -1933,16 +2035,11 @@ fn run_elevated_singbox_service_command(
|
||||
"$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode",
|
||||
escape_powershell_single(&script_path.display().to_string())
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
launch_script.as_str(),
|
||||
])
|
||||
.output();
|
||||
let output = if is_running_elevated() {
|
||||
run_powershell_file(&script_path)
|
||||
} else {
|
||||
run_powershell_command(&launch_script)
|
||||
};
|
||||
|
||||
let _ = fs::remove_file(&script_path);
|
||||
|
||||
@@ -2218,16 +2315,11 @@ try {{
|
||||
escape_powershell_single(&result_path.display().to_string()),
|
||||
escape_powershell_single(&runner_path.display().to_string())
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
launch_script.as_str(),
|
||||
])
|
||||
.output();
|
||||
let output = if is_running_elevated() {
|
||||
run_powershell_file(&runner_path)
|
||||
} else {
|
||||
run_powershell_command(&launch_script)
|
||||
};
|
||||
|
||||
let _ = fs::remove_file(&installer_path);
|
||||
let _ = fs::remove_file(&runner_path);
|
||||
@@ -2781,16 +2873,11 @@ fn run_elevated_proxifyre_service_command(
|
||||
"$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode",
|
||||
escape_powershell_single(&script_path.display().to_string())
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
launch_script.as_str(),
|
||||
])
|
||||
.output();
|
||||
let output = if is_running_elevated() {
|
||||
run_powershell_file(&script_path)
|
||||
} else {
|
||||
run_powershell_command(&launch_script)
|
||||
};
|
||||
|
||||
let _ = fs::remove_file(&script_path);
|
||||
|
||||
@@ -3225,16 +3312,11 @@ try {{
|
||||
escape_powershell_single(&result_path.display().to_string()),
|
||||
escape_powershell_single(&script_path.display().to_string())
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
launch_script.as_str(),
|
||||
])
|
||||
.output();
|
||||
let output = if is_running_elevated() {
|
||||
run_powershell_file(&script_path)
|
||||
} else {
|
||||
run_powershell_command(&launch_script)
|
||||
};
|
||||
|
||||
let _ = fs::remove_file(&script_path);
|
||||
|
||||
@@ -3296,6 +3378,62 @@ fn write_powershell_script(path: &Path, script: &str) -> std::io::Result<()> {
|
||||
fs::write(path, bytes)
|
||||
}
|
||||
|
||||
fn run_powershell_command(script: &str) -> std::io::Result<Output> {
|
||||
Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
])
|
||||
.output()
|
||||
}
|
||||
|
||||
fn run_powershell_file(script_path: &Path) -> std::io::Result<Output> {
|
||||
Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
])
|
||||
.arg(script_path)
|
||||
.output()
|
||||
}
|
||||
|
||||
fn is_running_elevated() -> bool {
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#;
|
||||
let Ok(output) = run_powershell_command(script) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("true")
|
||||
}
|
||||
|
||||
fn powershell_output_message(output: &Output, fallback: &str) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if !stderr.is_empty() {
|
||||
return stderr;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
return stdout;
|
||||
}
|
||||
|
||||
fallback.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
let mut script = String::new();
|
||||
script.push_str(&format!(
|
||||
|
||||
@@ -36,6 +36,8 @@ fn main() {
|
||||
.manage(commands::CommandState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::get_admin_status,
|
||||
commands::restart_as_admin,
|
||||
commands::get_profiles,
|
||||
commands::get_saved_state,
|
||||
commands::save_profile,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
16
src/ui/BusyRing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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