mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 04:21:29 +02:00
make main screen accessible
This commit is contained in:
@@ -20,6 +20,7 @@ type CopyToClipboardProps = {
|
||||
alwaysShowIcon?: boolean;
|
||||
variant?: CopyToClipboardVariant;
|
||||
"aria-label"?: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const CopyToClipboard = ({
|
||||
@@ -32,6 +33,7 @@ export const CopyToClipboard = ({
|
||||
alwaysShowIcon = false,
|
||||
variant = "default",
|
||||
"aria-label": ariaLabel,
|
||||
tabIndex = 0,
|
||||
}: CopyToClipboardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const wrapperRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -67,10 +69,12 @@ export const CopyToClipboard = ({
|
||||
type="button"
|
||||
ref={wrapperRef}
|
||||
onClick={handleClick}
|
||||
tabIndex={tabIndex}
|
||||
aria-label={resolvedLabel}
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left pointer-events-auto",
|
||||
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left pointer-events-auto rounded-sm outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -9,16 +9,19 @@ type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
{ icon: Icon, iconSize = 17, iconClassName, className, type = "button", ...props },
|
||||
{ icon: Icon, iconSize = 17, iconClassName, className, type = "button", disabled, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cn(
|
||||
"h-10 w-10 flex items-center justify-center rounded-lg cursor-default outline-none",
|
||||
"text-nb-gray-400 hover:text-nb-gray-300 hover:bg-nb-gray-900",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"transition-colors duration-150 wails-no-draggable",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -47,10 +47,12 @@ const ToggleSwitch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
|
||||
SwitchVariants & { dataCy?: string }
|
||||
>(({ className, size = "default", variant = "default", dataCy, ...props }, ref) => (
|
||||
>(({ className, size = "default", variant = "default", dataCy, disabled, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cn(
|
||||
"wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950",
|
||||
"wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
switchVariants({ size, variant }),
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
|
||||
type PeerDetailContextValue = {
|
||||
@@ -17,7 +25,26 @@ export const usePeerDetail = (): PeerDetailContextValue => {
|
||||
};
|
||||
|
||||
export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [selected, setSelected] = useState<PeerStatus | null>(null);
|
||||
const value = useMemo<PeerDetailContextValue>(() => ({ selected, setSelected }), [selected]);
|
||||
const [selected, setSelectedState] = useState<PeerStatus | null>(null);
|
||||
const openerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const setSelected = useCallback((p: PeerStatus | null) => {
|
||||
if (p) {
|
||||
const active = document.activeElement;
|
||||
openerRef.current = active instanceof HTMLElement ? active : null;
|
||||
} else {
|
||||
const opener = openerRef.current;
|
||||
openerRef.current = null;
|
||||
if (opener && opener.isConnected) {
|
||||
queueMicrotask(() => opener.focus());
|
||||
}
|
||||
}
|
||||
setSelectedState(p);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<PeerDetailContextValue>(
|
||||
() => ({ selected, setSelected }),
|
||||
[selected, setSelected],
|
||||
);
|
||||
return <PeerDetailContext.Provider value={value}>{children}</PeerDetailContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -256,8 +256,9 @@ export const MainConnectionStatusSwitch = () => {
|
||||
message={fqdn}
|
||||
variant={"bright"}
|
||||
iconClassName={"-top-px"}
|
||||
tabIndex={show && fqdn ? 0 : -1}
|
||||
className={cn(
|
||||
"min-h-[1em] transition-opacity duration-300 max-w-full",
|
||||
"min-h-[1em] max-h-[1em] mt-1 transition-opacity duration-300 max-w-full",
|
||||
"relative left-[0.55rem]",
|
||||
show && fqdn ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
@@ -265,7 +266,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
<TruncatedText
|
||||
text={shortenDns(fqdn) || " "}
|
||||
className={
|
||||
"block font-mono text-[0.8rem] leading-tight text-nb-gray-300 truncate max-w-[310px]"
|
||||
"block font-mono text-[0.8rem] leading-tight text-nb-gray-300 truncate max-w-[310px] h-[18px]"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
@@ -285,8 +286,9 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
<CopyToClipboard
|
||||
message={ip}
|
||||
variant={"bright"}
|
||||
tabIndex={show && ip ? 0 : -1}
|
||||
className={cn(
|
||||
"min-h-[1em] transition-opacity duration-300",
|
||||
"min-h-[1em] max-h-[1em] mt-1 transition-opacity duration-300",
|
||||
"relative left-[0.55rem]",
|
||||
show && ip ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
@@ -310,11 +312,13 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={show && ip ? 0 : -1}
|
||||
aria-label={t("connect.localIp.label")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"group relative inline-flex items-center outline-none cursor-default",
|
||||
"group relative inline-flex items-center outline-none cursor-default rounded-sm",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
@@ -379,12 +383,14 @@ const IpRow = ({ value }: { value: string }) => {
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={handleClick}
|
||||
tabIndex={0}
|
||||
aria-label={`${t("common.copy")} ${value}`}
|
||||
className={cn(
|
||||
"group/iprow relative flex items-center justify-between gap-3",
|
||||
"rounded-md px-2 py-1.5 text-left",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"transition-colors outline-none cursor-default",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
<span className={"font-mono text-[0.75rem] truncate min-w-0"}>{value}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useState } from "react";
|
||||
import { forwardRef, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
@@ -21,6 +21,15 @@ export const MainExitNodeSwitcher = () => {
|
||||
const disabled = !isConnected || !hasAny;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (open || disabled) return;
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (next: string) => {
|
||||
setOpen(false);
|
||||
@@ -53,6 +62,8 @@ export const MainExitNodeSwitcher = () => {
|
||||
active={!!active}
|
||||
aria-label={t("exitNodes.dropdown.trigger")}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
@@ -61,7 +72,10 @@ export const MainExitNodeSwitcher = () => {
|
||||
side={"top"}
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
listRef.current?.focus();
|
||||
}}
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
|
||||
@@ -72,8 +86,17 @@ export const MainExitNodeSwitcher = () => {
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
>
|
||||
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Command.List>
|
||||
<Command
|
||||
loop
|
||||
shouldFilter={false}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={"outline-none focus:outline-none focus-visible:outline-none"}
|
||||
>
|
||||
<Command.List
|
||||
ref={listRef}
|
||||
aria-label={t("exitNodes.dropdown.trigger")}
|
||||
className={"outline-none focus:outline-none focus-visible:outline-none"}
|
||||
>
|
||||
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
|
||||
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
|
||||
{hasAny && (
|
||||
@@ -127,12 +150,14 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
<button
|
||||
ref={ref}
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-2.5 pr-5 rounded-xl outline-none text-left",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"transition-colors duration-150",
|
||||
"wails-no-draggable",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-default hover:bg-nb-gray-935 hover:border-nb-gray-900 data-[state=open]:bg-nb-gray-935 data-[state=open]:border-nb-gray-900",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComponentType } from "react";
|
||||
import { ComponentType, KeyboardEvent, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -42,6 +42,39 @@ export const Navigation = () => {
|
||||
});
|
||||
}
|
||||
|
||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
const focusTab = (value: NavSection) => {
|
||||
setSection(value);
|
||||
requestAnimationFrame(() => tabRefs.current[value]?.focus());
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const enabled = tabs.filter((t) => isConnected || t.value === section);
|
||||
if (enabled.length < 2) return;
|
||||
const currentIndex = enabled.findIndex((t) => t.value === section);
|
||||
if (currentIndex === -1) return;
|
||||
let nextIndex: number | null = null;
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
nextIndex = (currentIndex + 1) % enabled.length;
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
nextIndex = (currentIndex - 1 + enabled.length) % enabled.length;
|
||||
break;
|
||||
case "Home":
|
||||
nextIndex = 0;
|
||||
break;
|
||||
case "End":
|
||||
nextIndex = enabled.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (nextIndex !== null) focusTab(enabled[nextIndex].value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role={"tablist"}
|
||||
@@ -49,13 +82,18 @@ export const Navigation = () => {
|
||||
aria-label={t("nav.peers.title")}
|
||||
className={"wails-no-draggable shrink-0 flex items-stretch "}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.value === section;
|
||||
const isDisabled = !isConnected && !isActive;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === tabs.length - 1;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
ref={(el) => {
|
||||
tabRefs.current[tab.value] = el;
|
||||
}}
|
||||
type={"button"}
|
||||
role={"tab"}
|
||||
aria-selected={isActive}
|
||||
@@ -63,11 +101,15 @@ export const Navigation = () => {
|
||||
id={`nb-tab-${tab.value}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => setSection(tab.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"group relative flex flex-1 items-center justify-center",
|
||||
"gap-2.5 px-5 py-3.5",
|
||||
"outline-none transition-all",
|
||||
isFirst && "rounded-tl-xl",
|
||||
isLast && "rounded-tr-xl",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
|
||||
isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-default",
|
||||
)}
|
||||
|
||||
@@ -37,11 +37,13 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
tabIndex={0}
|
||||
aria-label={t("common.filter")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import {
|
||||
KeyboardEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -207,6 +214,7 @@ export const Networks = () => {
|
||||
</span>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
onClick={onBulkClick}
|
||||
aria-label={t("networks.bulk.label")}
|
||||
className={cn(
|
||||
@@ -214,6 +222,7 @@ export const Networks = () => {
|
||||
"text-xs font-medium text-nb-gray-100",
|
||||
"bg-nb-gray-920 hover:bg-nb-gray-910 border border-nb-gray-900 hover:border-nb-gray-850",
|
||||
"transition-colors outline-none wails-no-draggable cursor-pointer",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{bulkLabel}
|
||||
@@ -234,15 +243,58 @@ const NetworksHeader = () => <div className={"h-2"} />;
|
||||
|
||||
const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
||||
const focusRow = (index: number) => {
|
||||
if (index < 0 || index >= data.length) return;
|
||||
const row = data[index];
|
||||
const tryFocus = () => {
|
||||
const el = rowRefs.current.get(row.id);
|
||||
if (el) {
|
||||
el.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!tryFocus()) {
|
||||
virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" });
|
||||
requestAnimationFrame(() => {
|
||||
if (!tryFocus()) requestAnimationFrame(tryFocus);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
focusRow(Math.min(index + 1, data.length - 1));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
focusRow(Math.max(index - 1, 0));
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
focusRow(0);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
focusRow(data.length - 1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
customScrollParent={scrollParent}
|
||||
increaseViewportBy={400}
|
||||
computeItemKey={(_, n) => n.id}
|
||||
components={{ Header: NetworksHeader }}
|
||||
itemContent={(_, n) => (
|
||||
itemContent={(index, n) => (
|
||||
<div
|
||||
role="listitem"
|
||||
className={cn(
|
||||
@@ -253,10 +305,19 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
ref={(el) => {
|
||||
if (el) rowRefs.current.set(n.id, el);
|
||||
else rowRefs.current.delete(n.id);
|
||||
}}
|
||||
aria-label={t("networks.row.toggle", { name: n.id })}
|
||||
aria-pressed={n.selected}
|
||||
onClick={() => onToggle(n.id, n.selected)}
|
||||
className={"absolute inset-0 cursor-pointer"}
|
||||
onKeyDown={(e) => handleRowKeyDown(e, index)}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-pointer outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
)}
|
||||
/>
|
||||
<ResourceIconBadge type={resourceTypeOf(n)} />
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { ComponentType, ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ComponentType,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
@@ -95,19 +103,68 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setSelected(null);
|
||||
if (e.key === "Escape") {
|
||||
setSelected(null);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return;
|
||||
setSelected(null);
|
||||
}
|
||||
};
|
||||
globalThis.addEventListener("keydown", onKey);
|
||||
return () => globalThis.removeEventListener("keydown", onKey);
|
||||
}, [selected, setSelected]);
|
||||
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const backButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
// Defer focus until the slide-in animation has started rendering.
|
||||
requestAnimationFrame(() => backButtonRef.current?.focus());
|
||||
}, [selected?.pubKey]);
|
||||
|
||||
const getFocusable = (): HTMLElement[] => {
|
||||
const root = dialogRef.current;
|
||||
if (!root) return [];
|
||||
const sel =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]),' +
|
||||
' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(sel)).filter(
|
||||
(el) => el.offsetParent !== null || el === document.activeElement,
|
||||
);
|
||||
};
|
||||
|
||||
const onDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const focusables = getFocusable();
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey) {
|
||||
if (active === first || !active || !dialogRef.current?.contains(active)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<motion.div
|
||||
ref={dialogRef}
|
||||
role={"dialog"}
|
||||
aria-modal={"true"}
|
||||
aria-labelledby={"nb-peer-detail-title"}
|
||||
onKeyDown={onDialogKeyDown}
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
@@ -121,13 +178,16 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
)}
|
||||
>
|
||||
<button
|
||||
ref={backButtonRef}
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
onClick={() => setSelected(null)}
|
||||
aria-label={t("common.close")}
|
||||
className={cn(
|
||||
"shrink-0 h-8 w-8 rounded-md flex items-center justify-center",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"transition-colors outline-none cursor-default",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
@@ -159,6 +219,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
<Tooltip content={t("peers.details.refresh")}>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
aria-label={t("peers.details.refresh")}
|
||||
@@ -167,6 +228,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
"shrink-0 h-8 w-8 rounded-md flex items-center justify-center",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"transition-colors outline-none cursor-default",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
)}
|
||||
@@ -386,6 +448,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
@@ -394,6 +457,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
"border border-nb-gray-900",
|
||||
"px-2 py-1 text-xs font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none transition-all",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{networks.length}
|
||||
|
||||
@@ -37,11 +37,13 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
tabIndex={0}
|
||||
aria-label={t("common.filter")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -168,15 +168,63 @@ type PeersListProps = {
|
||||
const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setSelected } = usePeerDetail();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
||||
const focusRow = (index: number) => {
|
||||
if (index < 0 || index >= data.length) return;
|
||||
const peer = data[index];
|
||||
const tryFocus = () => {
|
||||
const el = rowRefs.current.get(peer.pubKey);
|
||||
if (el) {
|
||||
el.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!tryFocus()) {
|
||||
virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" });
|
||||
// Row may not be mounted yet — retry after Virtuoso renders it.
|
||||
requestAnimationFrame(() => {
|
||||
if (!tryFocus()) requestAnimationFrame(tryFocus);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
focusRow(Math.min(index + 1, data.length - 1));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
focusRow(Math.max(index - 1, 0));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
setSelected(data[index]);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
focusRow(0);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
focusRow(data.length - 1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
customScrollParent={scrollParent}
|
||||
increaseViewportBy={400}
|
||||
computeItemKey={(_, peer) => peer.pubKey}
|
||||
components={{ Header: ListTopSpacer }}
|
||||
itemContent={(_, peer) => {
|
||||
itemContent={(index, peer) => {
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
const peerName = shortenDns(peer.fqdn) || peer.ip;
|
||||
const statusLabel = t(peerStatusLabelKey(peer.connStatus));
|
||||
@@ -191,12 +239,21 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
ref={(el) => {
|
||||
if (el) rowRefs.current.set(peer.pubKey, el);
|
||||
else rowRefs.current.delete(peer.pubKey);
|
||||
}}
|
||||
aria-label={t("peers.row.label", {
|
||||
name: peerName,
|
||||
status: statusLabel,
|
||||
})}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={"absolute inset-0 cursor-default"}
|
||||
onKeyDown={(e) => handleRowKeyDown(e, index)}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-default outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
)}
|
||||
/>
|
||||
<Tooltip content={statusLabel} side={"left"}>
|
||||
<span
|
||||
|
||||
@@ -22,6 +22,15 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (open) return;
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedProfiles = [...profiles].sort((a, b) => {
|
||||
if (a.id === activeProfileId) return -1;
|
||||
@@ -63,14 +72,21 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"} disabled={!hasProfile}>
|
||||
<ProfileTriggerButton name={displayName} disabled={!hasProfile} />
|
||||
<ProfileTriggerButton
|
||||
name={displayName}
|
||||
disabled={!hasProfile}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
listRef.current?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
"z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
@@ -82,12 +98,21 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
>
|
||||
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
|
||||
{sortedProfiles.length > 0 && (
|
||||
<>
|
||||
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
||||
<ScrollArea.Viewport className="max-h-60 px-1">
|
||||
<Command.List>
|
||||
<Command
|
||||
loop
|
||||
shouldFilter={false}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={"outline-none focus:outline-none focus-visible:outline-none"}
|
||||
>
|
||||
<Command.List
|
||||
ref={listRef}
|
||||
aria-label={t("header.profile.switch")}
|
||||
className={"outline-none focus:outline-none focus-visible:outline-none"}
|
||||
>
|
||||
{sortedProfiles.length > 0 && (
|
||||
<>
|
||||
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
||||
<ScrollArea.Viewport className="max-h-60 px-1">
|
||||
{sortedProfiles.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.id}
|
||||
@@ -96,40 +121,40 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation="vertical"
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
<div className="-mx-1 h-px bg-nb-gray-910" />
|
||||
</>
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation="vertical"
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
<div className="-mx-1 h-px bg-nb-gray-910" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={"pt-1"}>
|
||||
<Command.Item
|
||||
value={MANAGE_VALUE}
|
||||
onSelect={handleManage}
|
||||
disabled={!onManageProfiles}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Settings2 size={14} aria-hidden="true" className="shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t("profile.dropdown.manageProfiles")}
|
||||
</span>
|
||||
</Command.Item>
|
||||
</div>
|
||||
<div className={"pt-1"}>
|
||||
<Command.Item
|
||||
value={MANAGE_VALUE}
|
||||
onSelect={handleManage}
|
||||
disabled={!onManageProfiles}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Settings2 size={14} aria-hidden="true" className="shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t("profile.dropdown.manageProfiles")}
|
||||
</span>
|
||||
</Command.Item>
|
||||
</div>
|
||||
</Command.List>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
@@ -157,13 +182,15 @@ type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
};
|
||||
|
||||
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
|
||||
function ProfileTriggerButton({ name, className, ...props }, ref) {
|
||||
function ProfileTriggerButton({ name, className, disabled, ...props }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = pickProfileIcon(name) ?? UserCircle;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={t("header.profile.switch")}
|
||||
aria-haspopup="listbox"
|
||||
className={cn(
|
||||
@@ -171,6 +198,7 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900",
|
||||
"data-[state=open]:bg-nb-gray-900",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"transition-colors duration-150 wails-no-draggable",
|
||||
className,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user