From 79e7dce47ec2fabe3ac98cbdc31dcec9d9ec0386 Mon Sep 17 00:00:00 2001
From: Eduard Gert <kontakt@eduardgert.de>
Date: Thu, 18 Jun 2026 11:06:29 +0200
Subject: [PATCH] make settings accessible

 Conflicts:
	client/ui/frontend/src/modules/profiles/ProfilesTab.tsx
---
 .../src/components/LanguagePicker.tsx         |  12 +-
 .../frontend/src/components/VerticalTabs.tsx  |  10 +-
 .../src/components/buttons/Button.tsx         |   1 +
 .../components/switches/FancyToggleSwitch.tsx |   1 +
 .../main/advanced/networks/Networks.tsx       |   4 +-
 .../src/modules/main/advanced/peers/Peers.tsx |   4 +-
 .../src/modules/profiles/ProfilesTab.tsx      | 209 +++++++++++++-----
 .../src/modules/settings/SettingsAbout.tsx    |   6 +-
 .../src/modules/settings/SettingsSection.tsx  |   8 +-
 .../settings/SettingsTroubleshooting.tsx      |   5 +-
 10 files changed, 197 insertions(+), 63 deletions(-)

diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx
index 6c4c42f17..777cc010e 100644
--- a/client/ui/frontend/src/components/LanguagePicker.tsx
+++ b/client/ui/frontend/src/components/LanguagePicker.tsx
@@ -49,6 +49,14 @@ export function LanguagePicker() {
         [languages, i18n.language],
     );
 
+    const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
+        if (open) return;
+        if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+            e.preventDefault();
+            setOpen(true);
+        }
+    };
+
     const select = async (code: string) => {
         setOpen(false);
         if (busy || code === i18n.language) return;
@@ -76,7 +84,9 @@ export function LanguagePicker() {
                     <Popover.Trigger asChild>
                         <button
                             type={"button"}
+                            tabIndex={0}
                             disabled={busy || languages.length === 0}
+                            onKeyDown={handleTriggerKeyDown}
                             aria-label={t("settings.general.language.label")}
                             aria-haspopup="listbox"
                             aria-expanded={open}
@@ -86,6 +96,7 @@ export function LanguagePicker() {
                                 "border-neutral-200 dark:border-nb-gray-700",
                                 "text-xs font-semibold text-nb-gray-100 cursor-default outline-none",
                                 "hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
+                                "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
                                 "disabled:opacity-50",
                             )}
                         >
@@ -109,7 +120,6 @@ export function LanguagePicker() {
                         <Popover.Content
                             align={"start"}
                             sideOffset={6}
-                            onCloseAutoFocus={(e) => e.preventDefault()}
                             className={cn(
                                 "w-[var(--radix-popover-trigger-width)]",
                                 "rounded-lg border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50",
diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx
index 4e9f2d56d..4a4ed5be9 100644
--- a/client/ui/frontend/src/components/VerticalTabs.tsx
+++ b/client/ui/frontend/src/components/VerticalTabs.tsx
@@ -48,6 +48,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
                 "transition-colors duration-150",
                 "data-[state=active]:bg-nb-gray-930",
                 "data-[state=inactive]:hover:bg-nb-gray-935",
+                "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
                 className,
             )}
             {...props}
@@ -81,7 +82,14 @@ const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(function Verti
     { className, ...props },
     ref,
 ) {
-    return <Tabs.Content ref={ref} className={cn("outline-none", className)} {...props} />;
+    return (
+        <Tabs.Content
+            ref={ref}
+            tabIndex={-1}
+            className={cn("outline-none", className)}
+            {...props}
+        />
+    );
 });
 
 export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });
diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx
index 1f1691cf7..c05e6f2e1 100644
--- a/client/ui/frontend/src/components/buttons/Button.tsx
+++ b/client/ui/frontend/src/components/buttons/Button.tsx
@@ -143,6 +143,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
         <button
             ref={ref}
             type={type}
+            tabIndex={0}
             disabled={disabled || loading}
             aria-busy={loading || undefined}
             className={cn(
diff --git a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx
index 4e8ad8461..682a5abb8 100644
--- a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx
+++ b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx
@@ -83,6 +83,7 @@ export default function FancyToggleSwitch({
     return (
         <div
             onClick={handleClick}
+            {...(disabled ? { inert: "" } : {})}
             className={cn(
                 "cursor-default transition-all duration-300 relative z-[1]",
                 "inline-block text-left w-full",
diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx
index b341c5f10..ef5c1aeb9 100644
--- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx
+++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx
@@ -265,7 +265,7 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
         }
     };
 
-    const handleRowKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
+    const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>, index: number) => {
         switch (e.key) {
             case "ArrowDown":
                 e.preventDefault();
@@ -297,6 +297,7 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
             itemContent={(index, n) => (
                 <div
                     role="listitem"
+                    onKeyDown={(e) => handleRowKeyDown(e, index)}
                     className={cn(
                         "group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0",
                         "hover:bg-nb-gray-900/40 transition-colors",
@@ -313,7 +314,6 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
                         aria-label={t("networks.row.toggle", { name: n.id })}
                         aria-pressed={n.selected}
                         onClick={() => onToggle(n.id, n.selected)}
-                        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",
diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx
index 40131fe45..300d417f8 100644
--- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx
+++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx
@@ -191,7 +191,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
         }
     };
 
-    const handleRowKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
+    const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>, index: number) => {
         switch (e.key) {
             case "ArrowDown":
                 e.preventDefault();
@@ -231,6 +231,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
                 return (
                     <div
                         role="listitem"
+                        onKeyDown={(e) => handleRowKeyDown(e, index)}
                         className={cn(
                             "group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0",
                             "hover:bg-nb-gray-900/40 transition-colors",
@@ -249,7 +250,6 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
                                 status: statusLabel,
                             })}
                             onClick={() => setSelected(peer)}
-                            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",
diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx
index 727486dc6..4946ccff5 100644
--- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx
+++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx
@@ -1,4 +1,4 @@
-import { useLayoutEffect, useMemo, useRef, useState } from "react";
+import { KeyboardEvent, useLayoutEffect, useMemo, useRef, useState } from "react";
 import { useTranslation } from "react-i18next";
 import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
 import type { Profile } from "@bindings/services/models.js";
@@ -128,23 +128,13 @@ export function ProfilesTab() {
                         "bg-nb-gray-930/60 border border-nb-gray-900 rounded-xl overflow-hidden",
                     )}
                 >
-                    <table
-                        className={"w-full text-sm"}
-                        aria-label={t("settings.profiles.section.profiles")}
-                    >
-                        <tbody>
-                            {ordered.map((profile) => (
-                                <ProfileRow
-                                    key={profile.id}
-                                    profile={profile}
-                                    isActive={profile.id === activeProfileId}
-                                    onSwitch={() => handleSwitch(profile.id, profile.name)}
-                                    onDeregister={() => handleDeregister(profile.id, profile.name)}
-                                    onDelete={() => handleDelete(profile.id, profile.name)}
-                                />
-                            ))}
-                        </tbody>
-                    </table>
+                    <ProfilesTable
+                        ordered={ordered}
+                        activeProfileId={activeProfileId}
+                        onSwitch={handleSwitch}
+                        onDeregister={handleDeregister}
+                        onDelete={handleDelete}
+                    />
 
                     {loaded && ordered.length === 0 && (
                         <div
@@ -184,64 +174,169 @@ export function ProfilesTab() {
     );
 }
 
+type ProfilesTableProps = {
+    ordered: Profile[];
+    activeProfileId: string | undefined;
+    onSwitch: (id: string, name: string) => void;
+    onDeregister: (id: string, name: string) => void;
+    onDelete: (id: string, name: string) => void;
+};
+
+const ProfilesTable = ({
+    ordered,
+    activeProfileId,
+    onSwitch,
+    onDeregister,
+    onDelete,
+}: ProfilesTableProps) => {
+    const { t } = useTranslation();
+    const [focusedIndex, setFocusedIndex] = useState(0);
+    const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());
+
+    const focusRow = (index: number) => {
+        if (index < 0 || index >= ordered.length) return;
+        setFocusedIndex(index);
+        const el = rowRefs.current.get(ordered[index].id);
+        el?.focus();
+    };
+
+    const handleRowKeyDown = (e: KeyboardEvent<HTMLLIElement>, index: number) => {
+        switch (e.key) {
+            case "ArrowDown":
+                e.preventDefault();
+                focusRow(Math.min(index + 1, ordered.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(ordered.length - 1);
+                break;
+        }
+    };
+
+    const safeFocusedIndex = Math.min(focusedIndex, Math.max(0, ordered.length - 1));
+
+    return (
+        <ul
+            role={"list"}
+            className={"w-full text-sm flex flex-col"}
+            aria-label={t("settings.profiles.section.profiles")}
+        >
+            {ordered.map((profile, index) => (
+                <ProfileRow
+                    key={profile.id}
+                    profile={profile}
+                    isActive={profile.id === activeProfileId}
+                    isFocused={index === safeFocusedIndex}
+                    isFirst={index === 0}
+                    isLast={index === ordered.length - 1}
+                    rowRef={(el) => {
+                        if (el) rowRefs.current.set(profile.id, el);
+                        else rowRefs.current.delete(profile.id);
+                    }}
+                    onKeyDown={(e) => handleRowKeyDown(e, index)}
+                    onFocus={() => setFocusedIndex(index)}
+                    onSwitch={() => onSwitch(profile.id, profile.name)}
+                    onDeregister={() => onDeregister(profile.id, profile.name)}
+                    onDelete={() => onDelete(profile.id, profile.name)}
+                />
+            ))}
+        </ul>
+    );
+};
+
 type ProfileRowProps = {
     profile: Profile;
     isActive: boolean;
+    isFocused: boolean;
+    isFirst: boolean;
+    isLast: boolean;
+    rowRef: (el: HTMLLIElement | null) => void;
+    onKeyDown: (e: KeyboardEvent<HTMLLIElement>) => void;
+    onFocus: () => void;
     onSwitch: () => void;
     onDeregister: () => void;
     onDelete: () => void;
 };
 
-const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: ProfileRowProps) => {
+const ProfileRow = ({
+    profile,
+    isActive,
+    isFocused,
+    isFirst,
+    isLast,
+    rowRef,
+    onKeyDown,
+    onFocus,
+    onSwitch,
+    onDeregister,
+    onDelete,
+}: ProfileRowProps) => {
     const { t } = useTranslation();
     const Icon = pickProfileIcon(profile.name) ?? UserCircle;
     const showEmail = !!profile.email;
 
     return (
-        <tr className={"border-b border-nb-gray-910 last:border-b-0"}>
-            <td className={"px-4 py-2.5 align-middle"}>
-                <div
-                    className={cn(
-                        "flex gap-2 min-w-0 leading-tight",
-                        showEmail ? "items-start" : "items-center",
-                    )}
-                >
-                    <Icon
-                        size={15}
-                        aria-hidden="true"
-                        className={cn(
-                            "text-nb-gray-200 shrink-0",
-
-                            showEmail ? "mt-0.5" : "",
-                        )}
-                    />
-                    <div className={"flex flex-col min-w-0 flex-1 leading-tight"}>
-                        <div className={"flex items-center gap-2 min-w-0"}>
-                            <span
-                                className={
-                                    "truncate font-medium text-nb-gray-100 select-text cursor-text"
-                                }
-                            >
-                                {profile.name}
-                            </span>
-                            {isActive && <Badge>{t("settings.profiles.active")}</Badge>}
-                        </div>
-                        {showEmail && <TruncatedEmail email={profile.email} />}
+        <li
+            ref={rowRef}
+            tabIndex={isFocused ? 0 : -1}
+            onKeyDown={onKeyDown}
+            onFocus={onFocus}
+            aria-label={profile.name}
+            className={cn(
+                "flex items-center gap-4 px-4 py-2.5",
+                "border-b border-nb-gray-910 last:border-b-0",
+                "outline-none",
+                isFirst && "rounded-t-xl",
+                isLast && "rounded-b-xl",
+                "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
+            )}
+        >
+            <div
+                className={cn(
+                    "flex gap-2 min-w-0 leading-tight flex-1",
+                    showEmail ? "items-start" : "items-center",
+                )}
+            >
+                <Icon
+                    size={15}
+                    aria-hidden="true"
+                    className={cn("text-nb-gray-200 shrink-0", showEmail ? "mt-0.5" : "")}
+                />
+                <div className={"flex flex-col min-w-0 flex-1 leading-tight"}>
+                    <div className={"flex items-center gap-2 min-w-0"}>
+                        <span
+                            className={
+                                "truncate font-medium text-nb-gray-100 select-text cursor-text"
+                            }
+                        >
+                            {profile.name}
+                        </span>
+                        {isActive && <Badge>{t("settings.profiles.active")}</Badge>}
                     </div>
+                    {showEmail && <TruncatedEmail email={profile.email} />}
                 </div>
-            </td>
-            <td className={"px-4 py-2.5 text-right align-middle"}>
+            </div>
+            <div className={"shrink-0 text-right"}>
                 <RowActions
                     canSwitch={!isActive}
                     canDeregister={!!profile.email}
                     isDefault={profile.name === DEFAULT_PROFILE}
                     isActive={isActive}
+                    rowFocused={isFocused}
                     onSwitch={onSwitch}
                     onDeregister={onDeregister}
                     onDelete={onDelete}
                 />
-            </td>
-        </tr>
+            </div>
+        </li>
     );
 };
 
@@ -272,6 +367,7 @@ type RowActionsProps = {
     canDeregister: boolean;
     isDefault: boolean;
     isActive: boolean;
+    rowFocused: boolean;
     onSwitch: () => void;
     onDeregister: () => void;
     onDelete: () => void;
@@ -282,6 +378,7 @@ const RowActions = ({
     canDeregister,
     isDefault,
     isActive,
+    rowFocused,
     onSwitch,
     onDeregister,
     onDelete,
@@ -299,6 +396,7 @@ const RowActions = ({
                 icon={CircleMinus}
                 onClick={onDeregister}
                 hidden={!canDeregister}
+                tabbable={rowFocused}
             />
             <ActionIconButton
                 label={deleteLabel}
@@ -306,12 +404,14 @@ const RowActions = ({
                 onClick={onDelete}
                 variant={"danger"}
                 disabled={deleteDisabled}
+                tabbable={rowFocused}
             />
             <ActionIconButton
                 label={t("profile.selector.switchTo")}
                 icon={LogIn}
                 onClick={onSwitch}
                 hidden={!canSwitch}
+                tabbable={rowFocused}
             />
         </div>
     );
@@ -325,6 +425,7 @@ type ActionIconButtonProps = {
     /** Occupies space but invisible and non-interactive (preserves row layout). */
     hidden?: boolean;
     disabled?: boolean;
+    tabbable?: boolean;
 };
 
 const ActionIconButton = ({
@@ -334,6 +435,7 @@ const ActionIconButton = ({
     variant = "default",
     hidden = false,
     disabled = false,
+    tabbable = true,
 }: ActionIconButtonProps) => {
     const button = (
         <button
@@ -342,10 +444,11 @@ const ActionIconButton = ({
             aria-label={label}
             aria-hidden={hidden || undefined}
             aria-disabled={disabled || undefined}
-            tabIndex={hidden ? -1 : undefined}
+            tabIndex={hidden || !tabbable ? -1 : 0}
             className={cn(
                 "h-9 w-9 inline-flex items-center justify-center rounded-md cursor-default outline-none",
                 "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",
                 variant === "danger"
                     ? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10"
                     : "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx
index 567bb38f7..d97e0c602 100644
--- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx
+++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx
@@ -133,9 +133,10 @@ export function SettingsAbout() {
                     <button
                         key={url}
                         type={"button"}
+                        tabIndex={0}
                         onClick={() => openUrl(url)}
                         className={
-                            "inline-flex items-center gap-1.5 decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition"
+                            "inline-flex items-center gap-1.5 rounded-sm outline-none decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
                         }
                     >
                         <Icon aria-hidden="true" className={iconClassName ?? "h-3.5 w-3.5"} />
@@ -150,9 +151,10 @@ export function SettingsAbout() {
                     <button
                         key={link.url}
                         type={"button"}
+                        tabIndex={0}
                         onClick={() => openUrl(link.url)}
                         className={
-                            "decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition"
+                            "rounded-sm outline-none decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
                         }
                     >
                         {link.label}
diff --git a/client/ui/frontend/src/modules/settings/SettingsSection.tsx b/client/ui/frontend/src/modules/settings/SettingsSection.tsx
index 2994950b4..7b05864d8 100644
--- a/client/ui/frontend/src/modules/settings/SettingsSection.tsx
+++ b/client/ui/frontend/src/modules/settings/SettingsSection.tsx
@@ -13,7 +13,13 @@ export const SectionGroup = ({
     <section
         aria-label={title}
         aria-disabled={disabled || undefined}
-        className={cn("mb-8 last:mb-1 px-1", disabled && "opacity-30 pointer-events-none")}
+        tabIndex={disabled ? -1 : 0}
+        {...(disabled ? { inert: "" } : {})}
+        className={cn(
+            "mb-8 last:mb-1 px-1 rounded-md outline-none",
+            "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
+            disabled && "opacity-30 pointer-events-none",
+        )}
     >
         <h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
             {title}
diff --git a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx
index 2ed10f6d6..1bea57319 100644
--- a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx
+++ b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx
@@ -93,7 +93,10 @@ export function SettingsTroubleshooting() {
                     helpText={t("settings.troubleshooting.packets.help")}
                     disabled={!capture}
                 />
-                <div className={"flex items-center gap-6 justify-between"}>
+                <div
+                    className={"flex items-center gap-6 justify-between"}
+                    {...(!capture ? { inert: "" } : {})}
+                >
                     <div className={"flex-1 max-w-md"}>
                         <Label htmlFor={durationId} disabled={!capture}>
                             {t("settings.troubleshooting.duration.label")}
