mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-08 22:35:10 +02:00
Accessibility overhaul
This commit is contained in:
+2
-2
@@ -722,7 +722,7 @@ SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### @uiw/react-codemirror (4.25.4) — MIT
|
||||
### @uiw/react-codemirror (4.25.7) — MIT
|
||||
|
||||
*License file not found in package.*
|
||||
|
||||
@@ -1380,7 +1380,7 @@ SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### tailwind-merge (3.4.0) — MIT
|
||||
### tailwind-merge (3.5.0) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
@@ -465,7 +465,10 @@ export function App() {
|
||||
);
|
||||
|
||||
const settingsSidebarContent = (
|
||||
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
|
||||
<nav
|
||||
className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
||||
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Settings
|
||||
@@ -473,7 +476,7 @@ export function App() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseSettingsView}
|
||||
className="h-6 w-6 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
className="h-6 w-6 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title="Back to conversations"
|
||||
aria-label="Back to conversations"
|
||||
>
|
||||
@@ -486,16 +489,17 @@ export function App() {
|
||||
key={section}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors',
|
||||
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
settingsSection === section && 'bg-accent border-l-primary'
|
||||
)}
|
||||
aria-current={settingsSection === section ? 'true' : undefined}
|
||||
onClick={() => setSettingsSection(section)}
|
||||
>
|
||||
{SETTINGS_SECTION_LABELS[section]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from './ui/sonner';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Contact, Conversation, Favorite, RadioConfig } from '../types';
|
||||
@@ -28,11 +29,14 @@ export function ChatHeader({
|
||||
onOpenContactInfo,
|
||||
}: ChatHeaderProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-center gap-x-2 min-w-0 flex-1">
|
||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||
<span
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => onOpenContactInfo(conversation.id)}
|
||||
title="View contact info"
|
||||
>
|
||||
@@ -45,8 +49,15 @@ export function ChatHeader({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
<h2
|
||||
className={`flex-shrink-0 font-semibold text-base ${conversation.type === 'contact' && onOpenContactInfo ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
|
||||
role={conversation.type === 'contact' && onOpenContactInfo ? 'button' : undefined}
|
||||
tabIndex={conversation.type === 'contact' && onOpenContactInfo ? 0 : undefined}
|
||||
onKeyDown={
|
||||
conversation.type === 'contact' && onOpenContactInfo
|
||||
? handleKeyboardActivate
|
||||
: undefined
|
||||
}
|
||||
onClick={
|
||||
conversation.type === 'contact' && onOpenContactInfo
|
||||
? () => onOpenContactInfo(conversation.id)
|
||||
@@ -59,9 +70,12 @@ export function ChatHeader({
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
</h2>
|
||||
<span
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
@@ -90,17 +104,18 @@ export function ChatHeader({
|
||||
{/* Direct trace button (contacts only) */}
|
||||
{conversation.type === 'contact' && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
aria-label="Direct Trace"
|
||||
>
|
||||
🛎
|
||||
<span aria-hidden="true">🛎</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Favorite button */}
|
||||
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() =>
|
||||
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
|
||||
}
|
||||
@@ -109,6 +124,11 @@ export function ChatHeader({
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites'
|
||||
}
|
||||
aria-label={
|
||||
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites'
|
||||
}
|
||||
>
|
||||
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
|
||||
<span className="text-amber-400">★</span>
|
||||
@@ -120,7 +140,7 @@ export function ChatHeader({
|
||||
{/* Delete button */}
|
||||
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => {
|
||||
if (conversation.type === 'channel') {
|
||||
onDeleteChannel(conversation.id);
|
||||
@@ -129,11 +149,12 @@ export function ChatHeader({
|
||||
}
|
||||
}}
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
🗑
|
||||
<span aria-hidden="true">🗑</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function ContactAvatar({
|
||||
height: size,
|
||||
fontSize: size * 0.45,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{avatar.text}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
@@ -110,6 +111,9 @@ export function ContactInfoPane({
|
||||
</h2>
|
||||
<span
|
||||
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(contact.public_key);
|
||||
toast.success('Public key copied!');
|
||||
@@ -167,6 +171,9 @@ export function ContactInfoPane({
|
||||
<SectionLabel>Location</SectionLabel>
|
||||
<span
|
||||
className="text-sm font-mono cursor-pointer hover:text-primary hover:underline transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => {
|
||||
const url =
|
||||
window.location.origin +
|
||||
@@ -256,6 +263,9 @@ export function ContactInfoPane({
|
||||
? 'cursor-pointer hover:text-primary transition-colors truncate'
|
||||
: 'truncate'
|
||||
}
|
||||
role={onNavigateToChannel ? 'button' : undefined}
|
||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import type { Contact } from '../types';
|
||||
|
||||
interface ContactStatusInfoProps {
|
||||
@@ -30,6 +31,9 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Reset path to flood?')) {
|
||||
@@ -49,6 +53,9 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Reset path to flood?')) {
|
||||
@@ -74,6 +81,9 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
||||
<span key="coords">
|
||||
<span
|
||||
className="font-mono cursor-pointer hover:text-primary hover:underline"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const url =
|
||||
|
||||
@@ -526,7 +526,7 @@ export function CrackerPanel({
|
||||
onClick={isRunning ? handleStop : handleStart}
|
||||
disabled={!wordlistLoaded || gpuAvailable === false}
|
||||
className={cn(
|
||||
'w-48 px-4 py-1.5 rounded text-sm font-medium',
|
||||
'w-48 px-4 py-1.5 rounded text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isRunning
|
||||
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
@@ -548,7 +548,7 @@ export function CrackerPanel({
|
||||
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Cracked: <span className="text-green-500 font-medium">{crackedCount}</span>
|
||||
Cracked: <span className="text-green-400 font-medium">{crackedCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Failed: <span className="text-destructive font-medium">{failedCount}</span>
|
||||
|
||||
@@ -186,7 +186,11 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{isRepeater && <span title="Repeater">🛜</span>}
|
||||
{isRepeater && (
|
||||
<span title="Repeater" aria-hidden="true">
|
||||
🛜
|
||||
</span>
|
||||
)}
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
|
||||
@@ -160,6 +160,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
name="chat-message-input"
|
||||
aria-label={placeholder || 'Type a message'}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
@@ -185,7 +186,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
className={cn(
|
||||
'tabular-nums',
|
||||
limitState === 'error' || limitState === 'danger'
|
||||
? 'text-red-500 font-medium'
|
||||
? 'text-red-400 font-medium'
|
||||
: limitState === 'warning'
|
||||
? 'text-yellow-500'
|
||||
: 'text-muted-foreground'
|
||||
@@ -195,7 +196,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
{remaining < 0 && ` (${remaining})`}
|
||||
</span>
|
||||
{warningMessage && (
|
||||
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
|
||||
<span className={cn(limitState === 'error' ? 'text-red-400' : 'text-yellow-500')}>
|
||||
— {warningMessage}
|
||||
</span>
|
||||
)}
|
||||
@@ -208,7 +209,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
className={cn(
|
||||
'tabular-nums',
|
||||
limitState === 'error' || limitState === 'danger'
|
||||
? 'text-red-500 font-medium'
|
||||
? 'text-red-400 font-medium'
|
||||
: limitState === 'warning'
|
||||
? 'text-yellow-500'
|
||||
: 'text-muted-foreground'
|
||||
@@ -219,7 +220,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
</span>
|
||||
)}
|
||||
{warningMessage && (
|
||||
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
|
||||
<span className={cn(limitState === 'error' ? 'text-red-400' : 'text-yellow-500')}>
|
||||
— {warningMessage}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { PathModal } from './PathModal';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -125,6 +126,9 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
@@ -377,7 +381,7 @@ export function MessageList({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">
|
||||
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground" role="status">
|
||||
Loading messages...
|
||||
</div>
|
||||
);
|
||||
@@ -404,9 +408,11 @@ export function MessageList({
|
||||
className="h-full overflow-y-auto p-4 flex flex-col gap-0.5"
|
||||
ref={listRef}
|
||||
onScroll={handleScroll}
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
>
|
||||
{loadingOlder && (
|
||||
<div className="text-center py-2 text-muted-foreground text-sm">
|
||||
<div className="text-center py-2 text-muted-foreground text-sm" role="status">
|
||||
Loading older messages...
|
||||
</div>
|
||||
)}
|
||||
@@ -469,6 +475,15 @@ export function MessageList({
|
||||
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
|
||||
{showAvatar && avatarKey && (
|
||||
<span
|
||||
role={
|
||||
onOpenContactInfo && !avatarKey.startsWith('name:') ? 'button' : undefined
|
||||
}
|
||||
tabIndex={onOpenContactInfo && !avatarKey.startsWith('name:') ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onOpenContactInfo && !avatarKey.startsWith('name:')
|
||||
? handleKeyboardActivate
|
||||
: undefined
|
||||
}
|
||||
onClick={
|
||||
onOpenContactInfo && !avatarKey.startsWith('name:')
|
||||
? () => onOpenContactInfo(avatarKey)
|
||||
@@ -496,6 +511,9 @@ export function MessageList({
|
||||
{canClickSender ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => onSenderClick(displaySender)}
|
||||
title={`Mention ${displaySender}`}
|
||||
>
|
||||
@@ -552,6 +570,9 @@ export function MessageList({
|
||||
msg.paths && msg.paths.length > 0 ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
@@ -569,6 +590,9 @@ export function MessageList({
|
||||
) : onResendChannelMessage && msg.type === 'CHAN' ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
@@ -600,8 +624,9 @@ export function MessageList({
|
||||
{showScrollToBottom && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-4 right-4 w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105"
|
||||
className="absolute bottom-4 right-4 w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title="Scroll to bottom"
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -174,7 +174,15 @@ export function NewMessageModal({
|
||||
contacts.map((contact) => (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
className="cursor-pointer px-4 py-2 hover:bg-accent"
|
||||
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
onSelectConversation({
|
||||
type: 'contact',
|
||||
@@ -246,8 +254,9 @@ export function NewMessageModal({
|
||||
setRoomKey(hex);
|
||||
}}
|
||||
title="Generate random key"
|
||||
aria-label="Generate random key"
|
||||
>
|
||||
🎲
|
||||
<span aria-hidden="true">🎲</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,7 +318,11 @@ export function NewMessageModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -1649,7 +1649,12 @@ export function PacketVisualizer3D({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full bg-background relative overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full bg-background relative overflow-hidden"
|
||||
role="img"
|
||||
aria-label="3D mesh network visualizer showing radio nodes as colored spheres and packet transmissions as animated arcs between them"
|
||||
>
|
||||
{/* Legend */}
|
||||
{showControls && (
|
||||
<div className="absolute bottom-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border z-10">
|
||||
|
||||
@@ -426,6 +426,14 @@ function CoordinateLink({ lat, lon, publicKey }: { lat: number; lon: number; pub
|
||||
return (
|
||||
<span
|
||||
className="text-xs text-muted-foreground font-mono cursor-pointer hover:text-primary hover:underline ml-1"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
title="View on map"
|
||||
>
|
||||
|
||||
@@ -209,7 +209,12 @@ export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 flex flex-col gap-2" ref={listRef}>
|
||||
<div
|
||||
className="h-full overflow-y-auto p-4 flex flex-col gap-2"
|
||||
ref={listRef}
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
>
|
||||
{sortedPackets.map(({ packet, decoded }) => (
|
||||
<div
|
||||
key={getRawPacketObservationKey(packet)}
|
||||
@@ -225,7 +230,11 @@ export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
</span>
|
||||
|
||||
{/* Encryption status */}
|
||||
{!packet.decrypted && <span title="Encrypted">🔒</span>}
|
||||
{!packet.decrypted && (
|
||||
<span title="Encrypted" aria-hidden="true">
|
||||
🔒
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<span
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from './ui/button';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Contact, Conversation, Favorite } from '../types';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
@@ -69,11 +70,14 @@ export function RepeaterDashboard({
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
||||
<span className="flex-shrink-0 font-semibold text-base">{conversation.name}</span>
|
||||
<span
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success('Contact key copied!');
|
||||
@@ -91,22 +95,24 @@ export function RepeaterDashboard({
|
||||
size="sm"
|
||||
onClick={loadAll}
|
||||
disabled={anyLoading}
|
||||
className="text-xs border-green-600 text-green-600 hover:bg-green-600/10 hover:text-green-600"
|
||||
className="text-xs border-green-400 text-green-400 hover:bg-green-400/10 hover:text-green-400"
|
||||
>
|
||||
{anyLoading ? 'Loading...' : 'Load All'}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
aria-label="Direct Trace"
|
||||
>
|
||||
🛎
|
||||
<span aria-hidden="true">🛎</span>
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onToggleFavorite('contact', conversation.id)}
|
||||
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFav ? (
|
||||
<span className="text-amber-400">★</span>
|
||||
@@ -115,14 +121,15 @@ export function RepeaterDashboard({
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onDeleteContact(conversation.id)}
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
🗑
|
||||
<span aria-hidden="true">🗑</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
|
||||
@@ -47,11 +47,16 @@ export function RepeaterLogin({
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Repeater password..."
|
||||
aria-label="Repeater password"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-destructive text-center">{error}</p>}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive text-center" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
|
||||
@@ -141,14 +141,21 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
: 'w-full h-full overflow-y-auto space-y-3';
|
||||
|
||||
const sectionButtonClasses =
|
||||
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40';
|
||||
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset';
|
||||
|
||||
const renderSectionHeader = (section: SettingsSection): ReactNode => {
|
||||
if (!showSectionButton) return null;
|
||||
return (
|
||||
<button type="button" className={sectionButtonClasses} onClick={() => toggleSection(section)}>
|
||||
<span className="font-medium">{SETTINGS_SECTION_LABELS[section]}</span>
|
||||
<span className="text-muted-foreground md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className={sectionButtonClasses}
|
||||
aria-expanded={expandedSections[section]}
|
||||
onClick={() => toggleSection(section)}
|
||||
>
|
||||
<span className="font-medium" role="heading" aria-level={3}>
|
||||
{SETTINGS_SECTION_LABELS[section]}
|
||||
</span>
|
||||
<span className="text-muted-foreground md:hidden" aria-hidden="true">
|
||||
{expandedSections[section] ? '−' : '+'}
|
||||
</span>
|
||||
</button>
|
||||
@@ -164,7 +171,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
) : (
|
||||
<div className={settingsContainerClass}>
|
||||
{shouldRenderSection('radio') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('radio')}
|
||||
{isSectionVisible('radio') && (
|
||||
<SettingsRadioSection
|
||||
@@ -176,11 +183,11 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('identity') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('identity')}
|
||||
{isSectionVisible('identity') && appSettings && (
|
||||
<SettingsIdentitySection
|
||||
@@ -197,11 +204,11 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('connectivity') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('connectivity')}
|
||||
{isSectionVisible('connectivity') && appSettings && (
|
||||
<SettingsConnectivitySection
|
||||
@@ -214,11 +221,11 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('database') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('database')}
|
||||
{isSectionVisible('database') && appSettings && (
|
||||
<SettingsDatabaseSection
|
||||
@@ -230,11 +237,11 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('bot') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('bot')}
|
||||
{isSectionVisible('bot') && appSettings && (
|
||||
<SettingsBotSection
|
||||
@@ -244,11 +251,11 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('mqtt') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('mqtt')}
|
||||
{isSectionVisible('mqtt') && appSettings && (
|
||||
<SettingsMqttSection
|
||||
@@ -258,23 +265,23 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('statistics') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('statistics')}
|
||||
{isSectionVisible('statistics') && (
|
||||
<SettingsStatisticsSection className={sectionContentClass} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('about') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('about')}
|
||||
{isSectionVisible('about') && <SettingsAboutSection className={sectionContentClass} />}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../types';
|
||||
import { getStateKey, type ConversationTimes, type SortOrder } from '../utils/conversationState';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { Input } from './ui/input';
|
||||
@@ -378,10 +379,14 @@ export function Sidebar({
|
||||
<div
|
||||
key={row.key}
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors',
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive(row.type, row.id) && 'bg-accent border-l-primary',
|
||||
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: row.type,
|
||||
@@ -407,6 +412,7 @@ export function Sidebar({
|
||||
? 'bg-destructive text-destructive-foreground'
|
||||
: 'bg-primary/90 text-primary-foreground'
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
@@ -444,30 +450,37 @@ export function Sidebar({
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors',
|
||||
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
|
||||
isSearching && 'cursor-default'
|
||||
)}
|
||||
aria-expanded={!effectiveCollapsed}
|
||||
onClick={() => {
|
||||
if (!isSearching) onToggle();
|
||||
}}
|
||||
title={effectiveCollapsed ? `Expand ${title}` : `Collapse ${title}`}
|
||||
>
|
||||
<span className="text-[9px]">{effectiveCollapsed ? '▸' : '▾'}</span>
|
||||
<span className="text-[9px]" aria-hidden="true">
|
||||
{effectiveCollapsed ? '▸' : '▾'}
|
||||
</span>
|
||||
<span>{title}</span>
|
||||
</button>
|
||||
{(showSortToggle || unreadCount > 0) && (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{showSortToggle && (
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors"
|
||||
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={handleSortToggle}
|
||||
aria-label={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||
>
|
||||
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
||||
</button>
|
||||
)}
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-secondary text-muted-foreground">
|
||||
<span
|
||||
className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-secondary text-muted-foreground"
|
||||
aria-label={`${unreadCount} unread`}
|
||||
>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -478,7 +491,10 @@ export function Sidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
|
||||
<nav
|
||||
className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col"
|
||||
aria-label="Conversations"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
||||
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
@@ -489,6 +505,7 @@ export function Sidebar({
|
||||
size="sm"
|
||||
onClick={onNewMessage}
|
||||
title="New Message"
|
||||
aria-label="New message"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
+
|
||||
@@ -500,15 +517,17 @@ export function Sidebar({
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-7 text-[13px] pr-8 bg-background/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -521,9 +540,13 @@ export function Sidebar({
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive('raw', 'raw') && 'bg-accent border-l-primary'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={isActive('raw', 'raw') ? 'page' : undefined}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: 'raw',
|
||||
@@ -532,7 +555,9 @@ export function Sidebar({
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">📡</span>
|
||||
<span className="text-muted-foreground text-xs" aria-hidden="true">
|
||||
📡
|
||||
</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">Packet Feed</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -541,9 +566,13 @@ export function Sidebar({
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive('map', 'map') && 'bg-accent border-l-primary'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={isActive('map', 'map') ? 'page' : undefined}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: 'map',
|
||||
@@ -552,7 +581,9 @@ export function Sidebar({
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">🗺️</span>
|
||||
<span className="text-muted-foreground text-xs" aria-hidden="true">
|
||||
🗺️
|
||||
</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">Node Map</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -561,9 +592,13 @@ export function Sidebar({
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive('visualizer', 'visualizer') && 'bg-accent border-l-primary'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={isActive('visualizer', 'visualizer') ? 'page' : undefined}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: 'visualizer',
|
||||
@@ -572,7 +607,9 @@ export function Sidebar({
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">✨</span>
|
||||
<span className="text-muted-foreground text-xs" aria-hidden="true">
|
||||
✨
|
||||
</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">Mesh Visualizer</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -581,12 +618,17 @@ export function Sidebar({
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
showCracker && 'bg-accent border-l-primary'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={onToggleCracker}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">🔓</span>
|
||||
<span className="text-muted-foreground text-xs" aria-hidden="true">
|
||||
🔓
|
||||
</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">
|
||||
{showCracker ? 'Hide' : 'Show'} Room Finder
|
||||
<span
|
||||
@@ -604,10 +646,15 @@ export function Sidebar({
|
||||
{/* Mark All Read */}
|
||||
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
|
||||
<div
|
||||
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]"
|
||||
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={onMarkAllRead}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">✓</span>
|
||||
<span className="text-muted-foreground text-xs" aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">Mark all as read</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -682,6 +729,6 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Menu } from 'lucide-react';
|
||||
import type { HealthStatus, RadioConfig } from '../types';
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatusBarProps {
|
||||
@@ -40,7 +41,7 @@ export function StatusBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
|
||||
<header className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
|
||||
{/* Mobile menu button - only visible on small screens */}
|
||||
{onMenuClick && (
|
||||
<button
|
||||
@@ -66,7 +67,11 @@ export function StatusBar({
|
||||
RemoteTerm
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
role="status"
|
||||
aria-label={connected ? 'Connected' : 'Disconnected'}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-colors',
|
||||
@@ -74,6 +79,7 @@ export function StatusBar({
|
||||
? 'bg-primary shadow-[0_0_6px_hsl(var(--primary)/0.5)]'
|
||||
: 'bg-muted-foreground'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden lg:inline text-muted-foreground">
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
@@ -85,6 +91,9 @@ export function StatusBar({
|
||||
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
|
||||
<span
|
||||
className="font-mono text-[11px] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(config.public_key);
|
||||
toast.success('Public key copied!');
|
||||
@@ -100,17 +109,17 @@ export function StatusBar({
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={reconnecting}
|
||||
className="px-3 py-1 bg-amber-500/10 border border-amber-500/20 text-amber-400 rounded-md text-xs cursor-pointer hover:bg-amber-500/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 bg-amber-500/10 border border-amber-500/20 text-amber-400 rounded-md text-xs cursor-pointer hover:bg-amber-500/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
className="px-3 py-1.5 bg-secondary border border-border text-muted-foreground rounded-md text-xs cursor-pointer hover:bg-accent hover:text-foreground transition-colors"
|
||||
className="px-3 py-1.5 bg-secondary border border-border text-muted-foreground rounded-md text-xs cursor-pointer hover:bg-accent hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{settingsMode ? 'Back to Chat' : 'Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,9 +42,10 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp
|
||||
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
|
||||
<span>Mesh Visualizer</span>
|
||||
<button
|
||||
className="hidden md:inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="hidden md:inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={toggleFullScreen}
|
||||
title={paneFullScreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
aria-label={paneFullScreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{paneFullScreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</button>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function AclPane({
|
||||
const permColor: Record<number, string> = {
|
||||
0: 'bg-muted text-muted-foreground',
|
||||
1: 'bg-blue-500/10 text-blue-500',
|
||||
2: 'bg-green-500/10 text-green-500',
|
||||
2: 'bg-green-400/10 text-green-400',
|
||||
3: 'bg-amber-500/10 text-amber-500',
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ export function ConsolePane({
|
||||
<div
|
||||
ref={outputRef}
|
||||
className="h-48 overflow-y-auto p-3 font-mono text-xs bg-black/50 text-green-400 space-y-1"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
>
|
||||
{history.length === 0 && (
|
||||
<p className="text-muted-foreground italic">Type a CLI command below...</p>
|
||||
@@ -65,6 +67,7 @@ export function ConsolePane({
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="CLI command..."
|
||||
aria-label="Console command"
|
||||
disabled={loading}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
|
||||
@@ -101,7 +101,7 @@ export function NeighborsPane({
|
||||
const dist = n.distance;
|
||||
const snrStr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
|
||||
const snrColor =
|
||||
n.snr >= 6 ? 'text-green-500' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-500';
|
||||
n.snr >= 6 ? 'text-green-400' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-400';
|
||||
return (
|
||||
<tr key={i} className="border-t border-border/50">
|
||||
<td className="py-1">{n.name || n.pubkey_prefix}</td>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function RadioSettingsPane({
|
||||
<span
|
||||
className={cn(
|
||||
'ml-2 text-xs',
|
||||
clockDrift.isLarge ? 'text-red-500' : 'text-muted-foreground'
|
||||
clockDrift.isLarge ? 'text-red-400' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
(drift: {clockDrift.text})
|
||||
|
||||
@@ -109,12 +109,13 @@ export function RepeaterPane({
|
||||
onClick={onRefresh}
|
||||
disabled={disabled || state.loading}
|
||||
className={cn(
|
||||
'p-1 rounded transition-colors disabled:opacity-50',
|
||||
'p-1 rounded transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
disabled || state.loading
|
||||
? 'text-muted-foreground'
|
||||
: 'text-green-500 hover:bg-accent hover:text-green-400'
|
||||
)}
|
||||
title="Refresh"
|
||||
aria-label={`Refresh ${title}`}
|
||||
>
|
||||
<RefreshIcon
|
||||
className={cn(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { handleKeyboardActivate } from '../../utils/a11y';
|
||||
import type { AppSettings, AppSettingsUpdate, BotConfig } from '../../types';
|
||||
|
||||
const BotCodeEditor = lazy(() =>
|
||||
@@ -141,7 +142,7 @@ export function SettingsBotSection({
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-md">
|
||||
<p className="text-sm text-red-500">
|
||||
<p className="text-sm text-red-400">
|
||||
<strong>Experimental:</strong> This is an alpha feature and introduces automated message
|
||||
sending to your radio; unexpected behavior may occur. Use with caution, and please report
|
||||
any bugs!
|
||||
@@ -182,12 +183,16 @@ export function SettingsBotSection({
|
||||
<div key={bot.id} className="border border-input rounded-md overflow-hidden">
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedBotId === bot.id}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('input, button')) return;
|
||||
setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
|
||||
}}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
{expandedBotId === bot.id ? '▼' : '▶'}
|
||||
</span>
|
||||
|
||||
@@ -211,6 +216,9 @@ export function SettingsBotSection({
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-medium flex-1 hover:text-primary cursor-text"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEditingName(bot);
|
||||
@@ -244,8 +252,9 @@ export function SettingsBotSection({
|
||||
handleDeleteBot(bot.id);
|
||||
}}
|
||||
title="Delete bot"
|
||||
aria-label="Delete bot"
|
||||
>
|
||||
🗑
|
||||
<span aria-hidden="true">🗑</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -303,7 +312,11 @@ export function SettingsBotSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Bot Settings'}
|
||||
|
||||
@@ -128,7 +128,11 @@ export function SettingsConnectivitySection({
|
||||
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
|
||||
</Button>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,6 +251,7 @@ export function SettingsDatabaseSection({
|
||||
onLocalLabelChange?.({ text, color: localLabelColor });
|
||||
}}
|
||||
placeholder="e.g. Home Base, Field Radio 2"
|
||||
aria-label="Local label text"
|
||||
className="flex-1"
|
||||
/>
|
||||
<input
|
||||
@@ -262,6 +263,7 @@ export function SettingsDatabaseSection({
|
||||
setLocalLabel(localLabelText, color);
|
||||
onLocalLabelChange?.({ text: localLabelText, color });
|
||||
}}
|
||||
aria-label="Local label color"
|
||||
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
|
||||
/>
|
||||
</div>
|
||||
@@ -271,7 +273,11 @@ export function SettingsDatabaseSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Settings'}
|
||||
|
||||
@@ -184,7 +184,11 @@ export function SettingsIdentitySection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,10 +125,13 @@ export function SettingsMqttSection({
|
||||
<div className="border border-input rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40"
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
aria-expanded={privateExpanded}
|
||||
onClick={() => setPrivateExpanded(!privateExpanded)}
|
||||
>
|
||||
<span className="text-muted-foreground">{privateExpanded ? '▼' : '▶'}</span>
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
{privateExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<h4 className="text-sm font-medium">Private MQTT Broker</h4>
|
||||
{health?.mqtt_status === 'connected' ? (
|
||||
<>
|
||||
@@ -308,10 +311,13 @@ export function SettingsMqttSection({
|
||||
<div className="border border-input rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40"
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
aria-expanded={communityExpanded}
|
||||
onClick={() => setCommunityExpanded(!communityExpanded)}
|
||||
>
|
||||
<span className="text-muted-foreground">{communityExpanded ? '▼' : '▶'}</span>
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
{communityExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<h4 className="text-sm font-medium">Community Analytics</h4>
|
||||
{health?.community_mqtt_status === 'connected' ? (
|
||||
<>
|
||||
@@ -427,7 +433,11 @@ export function SettingsMqttSection({
|
||||
{busy ? 'Saving...' : 'Save MQTT Settings'}
|
||||
</Button>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -276,7 +276,11 @@ export function SettingsRadioSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={busy || rebooting} className="w-full">
|
||||
{busy || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
|
||||
|
||||
@@ -88,8 +88,8 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
<span className="font-medium">{stats.total_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-green-500">Decrypted</span>
|
||||
<span className="font-medium text-green-500">{stats.decrypted_packets}</span>
|
||||
<span className="text-sm text-green-400">Decrypted</span>
|
||||
<span className="font-medium text-green-400">{stats.decrypted_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-yellow-500">Undecrypted</span>
|
||||
|
||||
@@ -16,7 +16,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
// Muted error style - dark red-tinted background with readable text
|
||||
error:
|
||||
'group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#d4a0a0]',
|
||||
'group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#e8b0b0]',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
--secondary: 224 12% 17%;
|
||||
--secondary-foreground: 213 12% 85%;
|
||||
--muted: 224 12% 15%;
|
||||
--muted-foreground: 218 10% 60%;
|
||||
--muted-foreground: 218 10% 65%;
|
||||
--accent: 224 11% 19%;
|
||||
--accent-foreground: 213 15% 90%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 224 11% 17%;
|
||||
--input: 224 11% 17%;
|
||||
--border: 224 11% 23%;
|
||||
--input: 224 11% 23%;
|
||||
--ring: 152 60% 38%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { KeyboardEvent } from 'react';
|
||||
|
||||
/** Activate a clickable non-button element on Enter or Space, mirroring native button behavior. */
|
||||
export function handleKeyboardActivate(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user