make settings accessible

 Conflicts:
	client/ui/frontend/src/modules/profiles/ProfilesTab.tsx
This commit is contained in:
Eduard Gert
2026-06-18 13:56:41 +02:00
parent 77fe022351
commit 79e7dce47e
10 changed files with 197 additions and 63 deletions
@@ -49,6 +49,14 @@ export function LanguagePicker() {
[languages, i18n.language], [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) => { const select = async (code: string) => {
setOpen(false); setOpen(false);
if (busy || code === i18n.language) return; if (busy || code === i18n.language) return;
@@ -76,7 +84,9 @@ export function LanguagePicker() {
<Popover.Trigger asChild> <Popover.Trigger asChild>
<button <button
type={"button"} type={"button"}
tabIndex={0}
disabled={busy || languages.length === 0} disabled={busy || languages.length === 0}
onKeyDown={handleTriggerKeyDown}
aria-label={t("settings.general.language.label")} aria-label={t("settings.general.language.label")}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
@@ -86,6 +96,7 @@ export function LanguagePicker() {
"border-neutral-200 dark:border-nb-gray-700", "border-neutral-200 dark:border-nb-gray-700",
"text-xs font-semibold text-nb-gray-100 cursor-default outline-none", "text-xs font-semibold text-nb-gray-100 cursor-default outline-none",
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600", "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", "disabled:opacity-50",
)} )}
> >
@@ -109,7 +120,6 @@ export function LanguagePicker() {
<Popover.Content <Popover.Content
align={"start"} align={"start"}
sideOffset={6} sideOffset={6}
onCloseAutoFocus={(e) => e.preventDefault()}
className={cn( className={cn(
"w-[var(--radix-popover-trigger-width)]", "w-[var(--radix-popover-trigger-width)]",
"rounded-lg border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50", "rounded-lg border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50",
@@ -48,6 +48,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
"transition-colors duration-150", "transition-colors duration-150",
"data-[state=active]:bg-nb-gray-930", "data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-935", "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, className,
)} )}
{...props} {...props}
@@ -81,7 +82,14 @@ const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(function Verti
{ className, ...props }, { className, ...props },
ref, 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 }); export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });
@@ -143,6 +143,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
<button <button
ref={ref} ref={ref}
type={type} type={type}
tabIndex={0}
disabled={disabled || loading} disabled={disabled || loading}
aria-busy={loading || undefined} aria-busy={loading || undefined}
className={cn( className={cn(
@@ -83,6 +83,7 @@ export default function FancyToggleSwitch({
return ( return (
<div <div
onClick={handleClick} onClick={handleClick}
{...(disabled ? { inert: "" } : {})}
className={cn( className={cn(
"cursor-default transition-all duration-300 relative z-[1]", "cursor-default transition-all duration-300 relative z-[1]",
"inline-block text-left w-full", "inline-block text-left w-full",
@@ -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) { switch (e.key) {
case "ArrowDown": case "ArrowDown":
e.preventDefault(); e.preventDefault();
@@ -297,6 +297,7 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
itemContent={(index, n) => ( itemContent={(index, n) => (
<div <div
role="listitem" role="listitem"
onKeyDown={(e) => handleRowKeyDown(e, index)}
className={cn( className={cn(
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0", "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", "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-label={t("networks.row.toggle", { name: n.id })}
aria-pressed={n.selected} aria-pressed={n.selected}
onClick={() => onToggle(n.id, n.selected)} onClick={() => onToggle(n.id, n.selected)}
onKeyDown={(e) => handleRowKeyDown(e, index)}
className={cn( className={cn(
"absolute inset-0 cursor-pointer outline-none", "absolute inset-0 cursor-pointer outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60", "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
@@ -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) { switch (e.key) {
case "ArrowDown": case "ArrowDown":
e.preventDefault(); e.preventDefault();
@@ -231,6 +231,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
return ( return (
<div <div
role="listitem" role="listitem"
onKeyDown={(e) => handleRowKeyDown(e, index)}
className={cn( className={cn(
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0", "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", "hover:bg-nb-gray-900/40 transition-colors",
@@ -249,7 +250,6 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
status: statusLabel, status: statusLabel,
})} })}
onClick={() => setSelected(peer)} onClick={() => setSelected(peer)}
onKeyDown={(e) => handleRowKeyDown(e, index)}
className={cn( className={cn(
"absolute inset-0 cursor-default outline-none", "absolute inset-0 cursor-default outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60", "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
@@ -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 { useTranslation } from "react-i18next";
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react"; import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
import type { Profile } from "@bindings/services/models.js"; 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", "bg-nb-gray-930/60 border border-nb-gray-900 rounded-xl overflow-hidden",
)} )}
> >
<table <ProfilesTable
className={"w-full text-sm"} ordered={ordered}
aria-label={t("settings.profiles.section.profiles")} activeProfileId={activeProfileId}
> onSwitch={handleSwitch}
<tbody> onDeregister={handleDeregister}
{ordered.map((profile) => ( onDelete={handleDelete}
<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>
{loaded && ordered.length === 0 && ( {loaded && ordered.length === 0 && (
<div <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 = { type ProfileRowProps = {
profile: Profile; profile: Profile;
isActive: boolean; isActive: boolean;
isFocused: boolean;
isFirst: boolean;
isLast: boolean;
rowRef: (el: HTMLLIElement | null) => void;
onKeyDown: (e: KeyboardEvent<HTMLLIElement>) => void;
onFocus: () => void;
onSwitch: () => void; onSwitch: () => void;
onDeregister: () => void; onDeregister: () => void;
onDelete: () => 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 { t } = useTranslation();
const Icon = pickProfileIcon(profile.name) ?? UserCircle; const Icon = pickProfileIcon(profile.name) ?? UserCircle;
const showEmail = !!profile.email; const showEmail = !!profile.email;
return ( return (
<tr className={"border-b border-nb-gray-910 last:border-b-0"}> <li
<td className={"px-4 py-2.5 align-middle"}> ref={rowRef}
<div tabIndex={isFocused ? 0 : -1}
className={cn( onKeyDown={onKeyDown}
"flex gap-2 min-w-0 leading-tight", onFocus={onFocus}
showEmail ? "items-start" : "items-center", aria-label={profile.name}
)} className={cn(
> "flex items-center gap-4 px-4 py-2.5",
<Icon "border-b border-nb-gray-910 last:border-b-0",
size={15} "outline-none",
aria-hidden="true" isFirst && "rounded-t-xl",
className={cn( isLast && "rounded-b-xl",
"text-nb-gray-200 shrink-0", "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
showEmail ? "mt-0.5" : "", >
)} <div
/> className={cn(
<div className={"flex flex-col min-w-0 flex-1 leading-tight"}> "flex gap-2 min-w-0 leading-tight flex-1",
<div className={"flex items-center gap-2 min-w-0"}> showEmail ? "items-start" : "items-center",
<span )}
className={ >
"truncate font-medium text-nb-gray-100 select-text cursor-text" <Icon
} size={15}
> aria-hidden="true"
{profile.name} className={cn("text-nb-gray-200 shrink-0", showEmail ? "mt-0.5" : "")}
</span> />
{isActive && <Badge>{t("settings.profiles.active")}</Badge>} <div className={"flex flex-col min-w-0 flex-1 leading-tight"}>
</div> <div className={"flex items-center gap-2 min-w-0"}>
{showEmail && <TruncatedEmail email={profile.email} />} <span
className={
"truncate font-medium text-nb-gray-100 select-text cursor-text"
}
>
{profile.name}
</span>
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
</div> </div>
{showEmail && <TruncatedEmail email={profile.email} />}
</div> </div>
</td> </div>
<td className={"px-4 py-2.5 text-right align-middle"}> <div className={"shrink-0 text-right"}>
<RowActions <RowActions
canSwitch={!isActive} canSwitch={!isActive}
canDeregister={!!profile.email} canDeregister={!!profile.email}
isDefault={profile.name === DEFAULT_PROFILE} isDefault={profile.name === DEFAULT_PROFILE}
isActive={isActive} isActive={isActive}
rowFocused={isFocused}
onSwitch={onSwitch} onSwitch={onSwitch}
onDeregister={onDeregister} onDeregister={onDeregister}
onDelete={onDelete} onDelete={onDelete}
/> />
</td> </div>
</tr> </li>
); );
}; };
@@ -272,6 +367,7 @@ type RowActionsProps = {
canDeregister: boolean; canDeregister: boolean;
isDefault: boolean; isDefault: boolean;
isActive: boolean; isActive: boolean;
rowFocused: boolean;
onSwitch: () => void; onSwitch: () => void;
onDeregister: () => void; onDeregister: () => void;
onDelete: () => void; onDelete: () => void;
@@ -282,6 +378,7 @@ const RowActions = ({
canDeregister, canDeregister,
isDefault, isDefault,
isActive, isActive,
rowFocused,
onSwitch, onSwitch,
onDeregister, onDeregister,
onDelete, onDelete,
@@ -299,6 +396,7 @@ const RowActions = ({
icon={CircleMinus} icon={CircleMinus}
onClick={onDeregister} onClick={onDeregister}
hidden={!canDeregister} hidden={!canDeregister}
tabbable={rowFocused}
/> />
<ActionIconButton <ActionIconButton
label={deleteLabel} label={deleteLabel}
@@ -306,12 +404,14 @@ const RowActions = ({
onClick={onDelete} onClick={onDelete}
variant={"danger"} variant={"danger"}
disabled={deleteDisabled} disabled={deleteDisabled}
tabbable={rowFocused}
/> />
<ActionIconButton <ActionIconButton
label={t("profile.selector.switchTo")} label={t("profile.selector.switchTo")}
icon={LogIn} icon={LogIn}
onClick={onSwitch} onClick={onSwitch}
hidden={!canSwitch} hidden={!canSwitch}
tabbable={rowFocused}
/> />
</div> </div>
); );
@@ -325,6 +425,7 @@ type ActionIconButtonProps = {
/** Occupies space but invisible and non-interactive (preserves row layout). */ /** Occupies space but invisible and non-interactive (preserves row layout). */
hidden?: boolean; hidden?: boolean;
disabled?: boolean; disabled?: boolean;
tabbable?: boolean;
}; };
const ActionIconButton = ({ const ActionIconButton = ({
@@ -334,6 +435,7 @@ const ActionIconButton = ({
variant = "default", variant = "default",
hidden = false, hidden = false,
disabled = false, disabled = false,
tabbable = true,
}: ActionIconButtonProps) => { }: ActionIconButtonProps) => {
const button = ( const button = (
<button <button
@@ -342,10 +444,11 @@ const ActionIconButton = ({
aria-label={label} aria-label={label}
aria-hidden={hidden || undefined} aria-hidden={hidden || undefined}
aria-disabled={disabled || undefined} aria-disabled={disabled || undefined}
tabIndex={hidden ? -1 : undefined} tabIndex={hidden || !tabbable ? -1 : 0}
className={cn( className={cn(
"h-9 w-9 inline-flex items-center justify-center rounded-md cursor-default outline-none", "h-9 w-9 inline-flex items-center justify-center rounded-md cursor-default outline-none",
"transition-colors duration-150", "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" variant === "danger"
? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10" ? "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", : "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
@@ -133,9 +133,10 @@ export function SettingsAbout() {
<button <button
key={url} key={url}
type={"button"} type={"button"}
tabIndex={0}
onClick={() => openUrl(url)} onClick={() => openUrl(url)}
className={ 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"} /> <Icon aria-hidden="true" className={iconClassName ?? "h-3.5 w-3.5"} />
@@ -150,9 +151,10 @@ export function SettingsAbout() {
<button <button
key={link.url} key={link.url}
type={"button"} type={"button"}
tabIndex={0}
onClick={() => openUrl(link.url)} onClick={() => openUrl(link.url)}
className={ 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} {link.label}
@@ -13,7 +13,13 @@ export const SectionGroup = ({
<section <section
aria-label={title} aria-label={title}
aria-disabled={disabled || undefined} 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"}> <h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
{title} {title}
@@ -93,7 +93,10 @@ export function SettingsTroubleshooting() {
helpText={t("settings.troubleshooting.packets.help")} helpText={t("settings.troubleshooting.packets.help")}
disabled={!capture} 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"}> <div className={"flex-1 max-w-md"}>
<Label htmlFor={durationId} disabled={!capture}> <Label htmlFor={durationId} disabled={!capture}>
{t("settings.troubleshooting.duration.label")} {t("settings.troubleshooting.duration.label")}