581 lines
23 KiB
TypeScript
581 lines
23 KiB
TypeScript
import {
|
|
Activity,
|
|
ArrowRightLeft,
|
|
Bell,
|
|
Bot,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
Columns2,
|
|
FileText,
|
|
Keyboard,
|
|
LogOut,
|
|
Map,
|
|
RefreshCw,
|
|
ShieldCheck,
|
|
X,
|
|
UserRound,
|
|
type LucideIcon
|
|
} from "lucide-react";
|
|
import { useRef, useState, type ReactNode } from "react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
import { ProjectSwitchDialog } from "./project-switch-dialog";
|
|
import {
|
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
|
MAP_READABLE_RADIUS_CLASS_NAME
|
|
} from "@/features/map/core";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import {
|
|
StatusBadge,
|
|
StatusDot,
|
|
StatusIcon as SemanticStatusIcon,
|
|
type StatusTone
|
|
} from "@/shared/ui/status";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger
|
|
} from "@/shared/ui/dropdown-menu";
|
|
import type { WorkbenchProject } from "../api/project-client";
|
|
import type { WorkbenchAlert, WorkbenchUser } from "../types";
|
|
import {
|
|
compactHeaderControlButtonClassName,
|
|
compactHeaderTextClassName,
|
|
headerControlButtonClassName,
|
|
headerControlIconClassName
|
|
} from "./workbench-top-bar-styles";
|
|
|
|
const menuSurfaceClassName = cn(
|
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
|
"acrylic-panel border p-2 text-slate-900"
|
|
);
|
|
const menuReadableRowClassName =
|
|
"surface-well focus:bg-[var(--action-soft)] focus:text-slate-950";
|
|
const alertActionClassName = cn(
|
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
|
"h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] transition-colors hover:border-[var(--action-blue)] hover:bg-[var(--action-blue)] hover:text-white focus:border-[var(--action-blue)] focus:bg-[var(--action-blue)] focus:text-white data-[highlighted]:border-[var(--action-blue)] data-[highlighted]:bg-[var(--action-blue)] data-[highlighted]:text-white active:scale-95"
|
|
);
|
|
|
|
export function AnalysisViewMenu({
|
|
open,
|
|
onOpenChange,
|
|
title,
|
|
subtitle,
|
|
sourceLabel,
|
|
splitAvailable,
|
|
hasSpatialView,
|
|
onShowResult,
|
|
onShowMapSplit,
|
|
onShowMapOnly,
|
|
onClose
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
title: string;
|
|
subtitle: string;
|
|
sourceLabel: string;
|
|
splitAvailable: boolean;
|
|
hasSpatialView: boolean;
|
|
onShowResult: () => void;
|
|
onShowMapSplit: () => void;
|
|
onShowMapOnly: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
return (
|
|
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn("group max-w-[220px] px-2", headerControlButtonClassName)}
|
|
aria-label={`当前分析视图:${title}`}
|
|
>
|
|
<span className={headerControlIconClassName}>
|
|
<Activity size={14} aria-hidden="true" />
|
|
</span>
|
|
<span className="hidden max-w-[160px] truncate text-sm font-semibold xl:inline 2xl:max-w-[200px]">
|
|
分析视图 · {title}
|
|
</span>
|
|
<ChevronDown size={14} className="hidden shrink-0 text-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)] 2xl:block" aria-hidden="true" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
|
|
<MenuHeader title={title} meta={`${subtitle} · ${sourceLabel}`} />
|
|
<MenuSectionLabel>查看方式</MenuSectionLabel>
|
|
<MenuAction icon={FileText} label="查看完整成果" description="打开指标、图表和结果表" tone="primary" onSelect={onShowResult} />
|
|
{splitAvailable ? (
|
|
<MenuAction icon={Columns2} label="半屏显示" description="分析报告与地图各占半屏" onSelect={onShowMapSplit} />
|
|
) : null}
|
|
{hasSpatialView ? (
|
|
<MenuAction icon={Map} label="仅看地图" description="保留当前分析图层并展开地图" onSelect={onShowMapOnly} />
|
|
) : null}
|
|
<MenuSeparator />
|
|
<MenuAction icon={X} label="关闭分析视图" description="移除当前成果及其地图图层" onSelect={onClose} />
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
export function AlertMenu({
|
|
open,
|
|
onOpenChange,
|
|
alerts,
|
|
sourceLabel,
|
|
compact = false,
|
|
conditionFeedVisible,
|
|
onSelectAlert,
|
|
onRemoveAlert,
|
|
onToggleConditionFeed,
|
|
onAnalyzeAlertsWithAgent
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
alerts: WorkbenchAlert[];
|
|
sourceLabel: string;
|
|
compact?: boolean;
|
|
conditionFeedVisible: boolean;
|
|
onSelectAlert: (alert: WorkbenchAlert) => void;
|
|
onRemoveAlert?: (alert: WorkbenchAlert) => void;
|
|
onToggleConditionFeed: () => void;
|
|
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
|
}) {
|
|
const reduceMotion = useReducedMotion();
|
|
const keyboardInteraction = useRef(false);
|
|
const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current;
|
|
const hasAlerts = alerts.length > 0;
|
|
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
|
|
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
|
|
const latestAlertTime = alerts[0]?.time;
|
|
|
|
return (
|
|
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"group relative px-2",
|
|
headerControlButtonClassName,
|
|
hasAlerts && !compact &&
|
|
(hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"),
|
|
hasAlerts && !compact &&
|
|
"border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
|
|
compact && compactHeaderControlButtonClassName
|
|
)}
|
|
aria-label={`查看待办与告警,当前 ${alerts.length} 条待处理事项`}
|
|
>
|
|
<span
|
|
className={cn(
|
|
headerControlIconClassName,
|
|
hasAlerts
|
|
? cn("status-icon", hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning")
|
|
: "bg-transparent!"
|
|
)}
|
|
>
|
|
<Bell size={14} aria-hidden="true" />
|
|
</span>
|
|
<span className={compact ? "sr-only" : compactHeaderTextClassName}>待办与告警</span>
|
|
{compact ? (
|
|
<StatusBadge
|
|
tone={alertSummaryTone}
|
|
className={cn(
|
|
"absolute right-0 top-0 min-h-4 min-w-4 justify-center px-1 py-0 leading-none",
|
|
!hasAlerts && "border-transparent! bg-transparent!"
|
|
)}
|
|
>
|
|
{alerts.length}
|
|
</StatusBadge>
|
|
) : (
|
|
<StatusBadge
|
|
tone={alertSummaryTone}
|
|
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
|
|
>
|
|
{alerts.length}
|
|
</StatusBadge>
|
|
)}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}
|
|
onKeyDownCapture={() => { keyboardInteraction.current = true; }}
|
|
onPointerDownCapture={() => { keyboardInteraction.current = false; }}
|
|
>
|
|
<AlertQueueSummary
|
|
count={alerts.length}
|
|
latestAlertTime={latestAlertTime}
|
|
conditionFeedVisible={conditionFeedVisible}
|
|
sourceLabel={sourceLabel}
|
|
tone={hasAlerts ? alertSummaryTone : "success"}
|
|
/>
|
|
<MenuSectionLabel>待处理事项</MenuSectionLabel>
|
|
<div className="scheduled-feed-scroll relative max-h-[min(52dvh,420px)] overflow-x-hidden overflow-y-auto pr-1">
|
|
<AnimatePresence initial={false} mode="popLayout" custom={skipRemovalMotion}>
|
|
{hasAlerts ? alerts.map((alert) => {
|
|
const tone = alert.severity === "warning" ? "warning" : "danger";
|
|
return (
|
|
<motion.div
|
|
key={alert.id}
|
|
data-alert-id={alert.id}
|
|
layout="position"
|
|
initial={false}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit="removed"
|
|
variants={{ removed: (skip: boolean) => ({
|
|
opacity: 0, x: skip ? 0 : 12, pointerEvents: "none",
|
|
transition: { duration: skip ? 0 : 0.18, ease: [0.4, 0, 1, 1] }
|
|
}) }}
|
|
transition={{ duration: skipRemovalMotion ? 0 : 0.18, ease: [0.4, 0, 1, 1] }}
|
|
className={cn(
|
|
tone === "warning" ? "status-tone-warning" : "status-tone-danger",
|
|
"relative my-1 min-h-[76px] border border-transparent px-3 py-2 transition-colors hover:border-[var(--action-border-hover)] hover:bg-[var(--action-selection-soft)] focus-within:border-[var(--action-border-hover)] focus-within:bg-[var(--action-selection-soft)]",
|
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
|
menuReadableRowClassName
|
|
)}
|
|
>
|
|
<DropdownMenuItem
|
|
asChild
|
|
className="absolute inset-0 z-0 h-full w-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 hover:bg-transparent focus:bg-transparent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]"
|
|
onSelect={() => onSelectAlert(alert)}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label={`打开通知:${alert.title}`}
|
|
/>
|
|
</DropdownMenuItem>
|
|
<div className="pointer-events-none relative">
|
|
<span className="flex min-w-0 items-center gap-2 pr-10 text-xs text-[var(--ink-muted)]">
|
|
<StatusDot tone={tone} size="md" />
|
|
<span className="font-mono tabular-nums">{alert.time}</span>
|
|
</span>
|
|
<p className="mt-1 break-words pr-10 text-sm font-semibold leading-5 text-slate-950">{alert.title}</p>
|
|
<p className="mt-0.5 break-words text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</p>
|
|
</div>
|
|
{onRemoveAlert ? (
|
|
<DropdownMenuItem
|
|
aria-label={`移除通知:${alert.title}`}
|
|
title="移除通知"
|
|
className={cn(alertActionClassName, "absolute right-1 top-1 z-10")}
|
|
onSelect={(event) => {
|
|
event.preventDefault();
|
|
onRemoveAlert(alert);
|
|
}}
|
|
>
|
|
<X size={14} aria-hidden="true" />
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
</motion.div>
|
|
);
|
|
}) : (
|
|
<div key="empty-alert-queue" className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
|
<div className="min-w-0">
|
|
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
|
|
<CheckCircle2 size={16} />
|
|
</SemanticStatusIcon>
|
|
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待处理事项</p>
|
|
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的待办和告警会进入这里。</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-[var(--surface-divider)]/70 pt-2">
|
|
<MenuPlainButton
|
|
label={conditionFeedVisible ? "工况面板已打开" : "打开工况面板"}
|
|
description="查看时间线与处置详情"
|
|
disabled={conditionFeedVisible}
|
|
onClick={() => {
|
|
onToggleConditionFeed();
|
|
onOpenChange(false);
|
|
}}
|
|
/>
|
|
<MenuPrimaryButton
|
|
disabled={!hasAlerts}
|
|
icon={Bot}
|
|
label={hasAlerts ? "工况汇总" : "等待提醒"}
|
|
onClick={() => {
|
|
void onAnalyzeAlertsWithAgent();
|
|
onOpenChange(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
export type WorkbenchProjectSelection = {
|
|
projects: WorkbenchProject[];
|
|
selectedProjectId: string;
|
|
pendingProjectId: string | null;
|
|
error: string | null;
|
|
onSelectProject: (projectId: string) => void;
|
|
onOpen?: () => void;
|
|
onRefresh?: () => void;
|
|
refreshing?: boolean;
|
|
};
|
|
|
|
export function UserMenu({
|
|
open,
|
|
onOpenChange,
|
|
user,
|
|
projectSelection,
|
|
onRefreshTiles,
|
|
onShowDataStatus,
|
|
onShowShortcuts,
|
|
onLogout
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
user: WorkbenchUser;
|
|
projectSelection?: WorkbenchProjectSelection;
|
|
onRefreshTiles: () => void;
|
|
onShowDataStatus: () => void;
|
|
onShowShortcuts: () => void;
|
|
onLogout?: () => Promise<void>;
|
|
}) {
|
|
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
|
const [projectDialogKeyboard, setProjectDialogKeyboard] = useState(false);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
className={cn(
|
|
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
|
|
headerControlButtonClassName,
|
|
compactHeaderControlButtonClassName
|
|
)}
|
|
aria-label="打开用户菜单"
|
|
>
|
|
<span className={headerControlIconClassName}>
|
|
<UserRound size={14} aria-hidden="true" />
|
|
</span>
|
|
<span className={cn(compactHeaderTextClassName, "text-sm font-semibold")}>{user.name}</span>
|
|
<ChevronDown size={14} className="hidden shrink-0 text-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)] 2xl:block" aria-hidden="true" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className={cn("z-[90] w-72", menuSurfaceClassName)}
|
|
onKeyDownCapture={() => setProjectDialogKeyboard(true)}
|
|
onPointerDownCapture={() => setProjectDialogKeyboard(false)}
|
|
onCloseAutoFocus={(event) => { if (projectDialogOpen) event.preventDefault(); }}>
|
|
<MenuHeader title={user.name} meta={user.role} icon={ShieldCheck} />
|
|
{projectSelection ? (
|
|
<>
|
|
<MenuAction icon={ArrowRightLeft} label="切换项目" description="选择要进入的供水项目"
|
|
onSelect={() => { onOpenChange(false); setProjectDialogOpen(true); projectSelection.onOpen?.(); }} />
|
|
<MenuSeparator />
|
|
</>
|
|
) : null}
|
|
<MenuSectionLabel>会话控制</MenuSectionLabel>
|
|
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
|
|
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
|
|
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
|
<MenuSeparator />
|
|
<DropdownMenuItem
|
|
disabled={!onLogout}
|
|
onSelect={onLogout ? () => void onLogout() : undefined}
|
|
className={cn(
|
|
"px-2 py-2",
|
|
!onLogout && "text-[var(--ink-muted)]",
|
|
MAP_COMPACT_RADIUS_CLASS_NAME
|
|
)}
|
|
>
|
|
<LogOut size={15} aria-hidden="true" />
|
|
退出登录
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<AnimatePresence>
|
|
{projectDialogOpen && projectSelection ? <ProjectSwitchDialog selection={projectSelection} keyboardOpened={projectDialogKeyboard}
|
|
onClose={() => setProjectDialogOpen(false)}
|
|
onRestoreFocus={() => triggerRef.current?.focus()} /> : null}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AlertQueueSummary({
|
|
count,
|
|
latestAlertTime,
|
|
conditionFeedVisible,
|
|
sourceLabel,
|
|
tone
|
|
}: {
|
|
count: number;
|
|
latestAlertTime?: string;
|
|
conditionFeedVisible: boolean;
|
|
sourceLabel: string;
|
|
tone: StatusTone;
|
|
}) {
|
|
const hasAlerts = count > 0;
|
|
|
|
return (
|
|
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex min-w-0 items-start gap-2.5">
|
|
<SemanticStatusIcon
|
|
tone={tone}
|
|
size="md"
|
|
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
|
|
>
|
|
{hasAlerts ? <Bell size={16} /> : <CheckCircle2 size={16} />}
|
|
</SemanticStatusIcon>
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">待办与告警</h2>
|
|
<p className="mt-0.5 text-xs leading-5 text-[var(--ink-muted)]">
|
|
{hasAlerts ? `${count} 条待处理${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待处理事项"}
|
|
</p>
|
|
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">业务告警来源:{sourceLabel}</p>
|
|
</div>
|
|
</div>
|
|
<StatusBadge tone={tone}>
|
|
{hasAlerts ? "待处理" : "暂无待办"}
|
|
</StatusBadge>
|
|
</div>
|
|
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-[var(--ink-muted)]">
|
|
<StatusDot tone={conditionFeedVisible ? "info" : "neutral"} />
|
|
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MenuPlainButton({
|
|
label,
|
|
description,
|
|
disabled = false,
|
|
onClick
|
|
}: {
|
|
label: string;
|
|
description: string;
|
|
disabled?: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
className={cn(
|
|
"flex h-11 min-w-0 flex-col justify-center border px-3 text-left transition-colors disabled:cursor-default disabled:opacity-70",
|
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
|
disabled
|
|
? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
|
|
: "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-800 hover:border-[var(--surface-border)] hover:bg-[var(--action-soft)]/55 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
|
|
)}
|
|
>
|
|
<span className="truncate text-xs font-semibold leading-4">{label}</span>
|
|
<span className="truncate text-xs font-medium leading-4 text-[var(--ink-muted)]">{description}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function MenuPrimaryButton({
|
|
icon: Icon,
|
|
label,
|
|
disabled = false,
|
|
onClick
|
|
}: {
|
|
icon: LucideIcon;
|
|
label: string;
|
|
disabled?: boolean;
|
|
onClick: () => void | Promise<void>;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
className={cn(
|
|
"inline-flex h-11 shrink-0 items-center justify-center gap-2 border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed",
|
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
|
disabled
|
|
? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
|
|
: "border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20 hover:bg-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
|
|
)}
|
|
>
|
|
<Icon size={14} aria-hidden="true" />
|
|
<span className="max-w-[92px] truncate">{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function MenuHeader({
|
|
title,
|
|
meta,
|
|
tone = "default",
|
|
icon: Icon
|
|
}: {
|
|
title: string;
|
|
meta: string;
|
|
tone?: "default" | "warning";
|
|
icon?: LucideIcon;
|
|
}) {
|
|
return (
|
|
<div className={cn("surface-control flex items-center justify-between gap-3 border px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
|
<div className="min-w-0">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
{Icon ? <Icon size={13} className="shrink-0 text-[var(--ink-muted)]" aria-hidden="true" /> : null}
|
|
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
|
|
</div>
|
|
<p className={cn("mt-0.5 truncate text-xs text-[var(--ink-muted)]", tone === "warning" && "status-tone-danger text-[var(--status-foreground)]")}>{meta}</p>
|
|
</div>
|
|
{tone === "warning" ? <StatusDot tone="danger" size="md" /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MenuSectionLabel({ children }: { children: ReactNode }) {
|
|
return <DropdownMenuLabel className="px-2 pb-1 pt-2.5 text-xs font-semibold uppercase text-[var(--ink-muted)]">{children}</DropdownMenuLabel>;
|
|
}
|
|
|
|
function MenuSeparator() {
|
|
return <DropdownMenuSeparator className="my-2 bg-[var(--surface-divider)]/70" />;
|
|
}
|
|
|
|
function MenuAction({
|
|
icon: Icon,
|
|
label,
|
|
description,
|
|
tone = "default",
|
|
onSelect
|
|
}: {
|
|
icon: LucideIcon;
|
|
label: string;
|
|
description: string;
|
|
tone?: "default" | "primary" | "warning";
|
|
onSelect: () => void;
|
|
}) {
|
|
return (
|
|
<DropdownMenuItem
|
|
className={cn(
|
|
"my-0.5 items-center border border-transparent px-2 py-2 transition",
|
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
|
menuReadableRowClassName
|
|
)}
|
|
onSelect={onSelect}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"grid h-7 w-7 shrink-0 place-items-center border",
|
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
|
tone === "primary" && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
|
|
tone === "warning" && "status-icon status-tone-warning",
|
|
tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-600"
|
|
)}
|
|
>
|
|
<Icon size={15} aria-hidden="true" />
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span>
|
|
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">{description}</span>
|
|
</span>
|
|
</DropdownMenuItem>
|
|
);
|
|
}
|