20260623 optimize rpki explorer ui interactions
This commit is contained in:
88
ui/rpki-explorer/src/components/common/CopyableValue.tsx
Normal file
88
ui/rpki-explorer/src/components/common/CopyableValue.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface CopyableValueProps {
|
||||
className?: string;
|
||||
displayValue?: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TooltipPosition {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export function CopyableValue({ className, displayValue, label, value }: CopyableValueProps) {
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const [tooltipPosition, setTooltipPosition] = useState<TooltipPosition | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState === "idle") {
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(() => setCopyState("idle"), 1600);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [copyState]);
|
||||
|
||||
const showTooltip = (target: HTMLElement) => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
setTooltipPosition({
|
||||
left: Math.min(window.innerWidth - 24, Math.max(24, rect.left + rect.width / 2)),
|
||||
top: rect.top > 90 ? rect.top - 10 : rect.bottom + 10
|
||||
});
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await copyText(value);
|
||||
setCopyState("copied");
|
||||
} catch {
|
||||
setCopyState("failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`copyable-value ${className ?? ""}`} data-copy-state={copyState} data-copyable-label={label}>
|
||||
<span
|
||||
className="copyable-value-text"
|
||||
onBlur={() => setTooltipPosition(null)}
|
||||
onFocus={(event) => showTooltip(event.currentTarget)}
|
||||
onMouseEnter={(event) => showTooltip(event.currentTarget)}
|
||||
onMouseLeave={() => setTooltipPosition(null)}
|
||||
tabIndex={0}
|
||||
title={value}
|
||||
>
|
||||
{displayValue ?? value}
|
||||
</span>
|
||||
<button aria-label={`Copy ${label}`} className="copy-icon-button" onClick={copy} title={`Copy ${label}`} type="button">
|
||||
{copyState === "copied" ? <Check aria-hidden="true" size={13} /> : <Copy aria-hidden="true" size={13} />}
|
||||
</button>
|
||||
{tooltipPosition ? (
|
||||
<span className="copyable-tooltip" role="tooltip" style={{ left: tooltipPosition.left, top: tooltipPosition.top }}>
|
||||
{value}
|
||||
</span>
|
||||
) : null}
|
||||
{copyState !== "idle" ? <span className="copy-state-text">{copyState === "copied" ? "Copied" : "Copy failed"}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function copyText(value: string) {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = value;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
32
ui/rpki-explorer/src/components/common/CursorPager.tsx
Normal file
32
ui/rpki-explorer/src/components/common/CursorPager.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
interface CursorPagerProps {
|
||||
label?: string;
|
||||
isFetching: boolean;
|
||||
nextCursor: string | null;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
pageNumber: number;
|
||||
previousCount: number;
|
||||
rangeLabel?: string;
|
||||
}
|
||||
|
||||
export function CursorPager({
|
||||
label,
|
||||
isFetching,
|
||||
nextCursor,
|
||||
onNext,
|
||||
onPrevious,
|
||||
pageNumber,
|
||||
previousCount,
|
||||
rangeLabel
|
||||
}: CursorPagerProps) {
|
||||
return (
|
||||
<div className="cursor-pager" aria-label="Cursor pagination">
|
||||
<button disabled={isFetching || previousCount === 0} onClick={onPrevious} type="button">Previous</button>
|
||||
<span>
|
||||
{label ?? `Page ${pageNumber}`}
|
||||
{rangeLabel ? <small>{rangeLabel}</small> : null}
|
||||
</span>
|
||||
<button disabled={isFetching || !nextCursor} onClick={onNext} type="button">Next</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
ui/rpki-explorer/src/components/common/cursorPaging.ts
Normal file
24
ui/rpki-explorer/src/components/common/cursorPaging.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface PageState {
|
||||
cursor: string | null;
|
||||
previous: Array<string | null>;
|
||||
}
|
||||
|
||||
export function advancePage(page: PageState, nextCursor: string | null): PageState {
|
||||
if (!nextCursor) {
|
||||
return page;
|
||||
}
|
||||
return {
|
||||
cursor: nextCursor,
|
||||
previous: [...page.previous, page.cursor]
|
||||
};
|
||||
}
|
||||
|
||||
export function previousPage(page: PageState): PageState {
|
||||
if (page.previous.length === 0) {
|
||||
return page;
|
||||
}
|
||||
return {
|
||||
cursor: page.previous[page.previous.length - 1] ?? null,
|
||||
previous: page.previous.slice(0, -1)
|
||||
};
|
||||
}
|
||||
@@ -1,26 +1,29 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { BarChart3, ChevronDown, CircleHelp, Search, Sun } from "lucide-react";
|
||||
import { PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface NavigationItem {
|
||||
id: string;
|
||||
export interface NavigationItem<Id extends string = string> {
|
||||
id: Id;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface ShellProps extends PropsWithChildren {
|
||||
activeView: string;
|
||||
navigationItems: NavigationItem[];
|
||||
onNavigate: (id: string) => void;
|
||||
interface ShellProps<Id extends string> extends PropsWithChildren {
|
||||
activeView: Id;
|
||||
navigationItems: Array<NavigationItem<Id>>;
|
||||
onNavigate: (id: Id) => void;
|
||||
}
|
||||
|
||||
export function Shell({ activeView, navigationItems, onNavigate, children }: ShellProps) {
|
||||
export function Shell<Id extends string>({ activeView, navigationItems, onNavigate, children }: ShellProps<Id>) {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className={`app-shell ${isSidebarCollapsed ? "sidebar-collapsed" : ""}`}>
|
||||
<aside className="sidebar" aria-label="Primary navigation">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">R</div>
|
||||
<div>
|
||||
<div className="brand-copy">
|
||||
<h1 className="brand-title">RPKI Explorer</h1>
|
||||
<div className="brand-subtitle">Validation Intelligence</div>
|
||||
</div>
|
||||
@@ -29,9 +32,11 @@ export function Shell({ activeView, navigationItems, onNavigate, children }: She
|
||||
{navigationItems.map((item) => (
|
||||
<button
|
||||
aria-current={activeView === item.id ? "page" : undefined}
|
||||
aria-label={isSidebarCollapsed ? item.label : undefined}
|
||||
className={`nav-item ${activeView === item.id ? "active" : ""}`}
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
title={isSidebarCollapsed ? item.label : undefined}
|
||||
type="button"
|
||||
>
|
||||
<item.icon aria-hidden="true" size={18} />
|
||||
@@ -39,24 +44,31 @@ export function Shell({ activeView, navigationItems, onNavigate, children }: She
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button
|
||||
aria-expanded={!isSidebarCollapsed}
|
||||
aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
||||
className="sidebar-toggle"
|
||||
onClick={() => setIsSidebarCollapsed((collapsed) => !collapsed)}
|
||||
title={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
||||
type="button"
|
||||
>
|
||||
{isSidebarCollapsed ? <PanelLeftOpen aria-hidden="true" size={18} /> : <PanelLeftClose aria-hidden="true" size={18} />}
|
||||
<span>{isSidebarCollapsed ? "Expand" : "Collapse"}</span>
|
||||
</button>
|
||||
</aside>
|
||||
<main className="main-panel">
|
||||
<header className="topbar">
|
||||
<div className="run-selector" aria-label="Current run">
|
||||
<div className="run-selector readonly-run" aria-label="Current run">
|
||||
<span>Run</span>
|
||||
<strong>latest run</strong>
|
||||
<ChevronDown aria-hidden="true" size={16} />
|
||||
<strong>latest indexed run</strong>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<label className="search-box">
|
||||
<Search aria-hidden="true" size={18} />
|
||||
<span className="sr-only">Search URI or hash</span>
|
||||
<input placeholder="Search URI / hash / ASN / prefix…" />
|
||||
<span className="sr-only">Exact object URI search is not active yet</span>
|
||||
<input disabled placeholder="Exact URI lookup planned in M2" />
|
||||
</label>
|
||||
<div className="ready-pill"><span /> Ready</div>
|
||||
<button aria-label="Help" className="icon-button" type="button"><CircleHelp size={19} /></button>
|
||||
<button aria-label="Theme" className="icon-button" type="button"><Sun size={19} /></button>
|
||||
<button aria-label="Analytics" className="icon-button" type="button"><BarChart3 size={19} /></button>
|
||||
<div className="ready-pill"><span /> UI Ready</div>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user