add a11y keys

This commit is contained in:
Eduard Gert
2026-06-18 13:56:41 +02:00
parent f6e50995b6
commit 9cdc6e3013
51 changed files with 761 additions and 153 deletions
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/cn";
@@ -18,6 +19,7 @@ type CopyToClipboardProps = {
iconClassName?: string;
alwaysShowIcon?: boolean;
variant?: CopyToClipboardVariant;
"aria-label"?: string;
};
export const CopyToClipboard = ({
@@ -29,7 +31,9 @@ export const CopyToClipboard = ({
iconClassName,
alwaysShowIcon = false,
variant = "default",
"aria-label": ariaLabel,
}: CopyToClipboardProps) => {
const { t } = useTranslation();
const wrapperRef = useRef<HTMLButtonElement>(null);
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -55,11 +59,16 @@ export const CopyToClipboard = ({
}
};
const resolvedLabel =
ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy"));
return (
<button
type="button"
ref={wrapperRef}
onClick={handleClick}
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",
className,
@@ -74,12 +83,14 @@ export const CopyToClipboard = ({
>
{children}
<span
aria-hidden="true"
className={
"absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500 pointer-events-none"
}
/>
</span>
<span
aria-hidden="true"
className={cn(
"shrink-0 inline-flex relative top-[2px] right-[1px]",
iconAlignment === "left" ? "order-first" : "order-last",
@@ -77,6 +77,9 @@ export function LanguagePicker() {
<button
type={"button"}
disabled={busy || languages.length === 0}
aria-label={t("settings.general.language.label")}
aria-haspopup="listbox"
aria-expanded={open}
className={cn(
"inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]",
"rounded-md border bg-white dark:bg-nb-gray-900",
@@ -86,11 +89,19 @@ export function LanguagePicker() {
"disabled:opacity-50",
)}
>
<LanguagesIcon size={16} className={"text-nb-gray-200 shrink-0"} />
<LanguagesIcon
size={16}
aria-hidden="true"
className={"text-nb-gray-200 shrink-0"}
/>
<span className={"truncate flex-1 text-left"}>
{current ? labelFor(current) : "—"}
</span>
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
<ChevronDown
size={12}
aria-hidden="true"
className={"text-nb-gray-400 shrink-0"}
/>
</button>
</Popover.Trigger>
@@ -119,11 +130,19 @@ export function LanguagePicker() {
)}
>
<div className={"px-1 pb-1"}>
<div className={"group flex items-center gap-2 px-1 h-8"}>
<Search size={14} className={"text-nb-gray-200 shrink-0"} />
<div
role="search"
className={"group flex items-center gap-2 px-1 h-8"}
>
<Search
size={14}
aria-hidden="true"
className={"text-nb-gray-200 shrink-0"}
/>
<Command.Input
autoFocus
placeholder={t("settings.general.language.search")}
aria-label={t("settings.general.language.search")}
className={cn(
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
"outline-none border-none",
@@ -162,6 +181,7 @@ export function LanguagePicker() {
{labelFor(lang)}
</span>
<span
aria-hidden="true"
className={
"w-4 shrink-0 flex items-center justify-center"
}
@@ -18,10 +18,16 @@ export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: P
key={i18n.language}
value={value}
onChange={(v) => onChange(v as ManagementMode)}
aria-label={t("settings.general.management.label")}
className={fullWidth ? "w-full" : undefined}
>
<SwitchItem value={ManagementMode.Cloud} className={itemClass}>
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
<img
src={netbirdLogo}
alt={""}
aria-hidden="true"
className={"h-[0.8rem] aspect-[31/23] shrink-0"}
/>
{t("settings.general.management.cloud")}
</SwitchItem>
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
@@ -25,6 +25,7 @@ export const SquareIcon = ({
className,
}: SquareIconProps) => (
<div
aria-hidden="true"
className={cn(
"h-11 w-11 rounded-lg flex items-center justify-center border bg-nb-gray-920 border-nb-gray-900",
variantClass[variant],
@@ -54,20 +54,25 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
>
<Icon
size={iconSize}
aria-hidden="true"
className={cn(
"shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
/>
<h2
<span
className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
>
{title}
</h2>
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
</span>
{adornment && (
<div aria-hidden="true" className={"ml-auto mr-2 shrink-0"}>
{adornment}
</div>
)}
</Tabs.Trigger>
);
});
@@ -144,6 +144,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={ref}
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={cn(
buttonVariants({
variant,
@@ -170,13 +171,20 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{...props}
>
{loading && (
<span className={"absolute inset-0 flex items-center justify-center"}>
<span
aria-hidden="true"
className={"absolute inset-0 flex items-center justify-center"}
>
<Loader2 size={iconSize} className={"animate-spin"} />
</span>
)}
<span className={cn("contents", loading && "invisible")}>
{copy !== undefined &&
(copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
(copied ? (
<Check size={iconSize} aria-hidden="true" />
) : (
<Copy size={iconSize} aria-hidden="true" />
))}
{children}
</span>
</button>
@@ -4,14 +4,22 @@ import { isMacOS } from "@/lib/platform.ts";
type ConfirmDialogProps = {
children: ReactNode;
"aria-label"?: string;
"aria-labelledby"?: string;
};
export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(function ConfirmDialog(
{ children },
{ children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy },
ref,
) {
return (
<div className={"wails-draggable select-none flex flex-col items-center"}>
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={"wails-draggable select-none flex flex-col items-center"}
>
<div
ref={ref}
className={cn(
@@ -32,6 +32,9 @@ export const ConfirmModal = ({
const { t } = useTranslation();
const resolvedCancel = cancelLabel ?? t("common.cancel");
const srTitle = typeof title === "string" ? title : undefined;
const srDescription = typeof description === "string" ? description : undefined;
return (
<Dialog.Root
open={open}
@@ -43,6 +46,8 @@ export const ConfirmModal = ({
maxWidthClass="max-w-sm"
showClose={false}
className="py-5"
srTitle={srTitle}
srDescription={srDescription}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-5 px-5">
@@ -54,7 +59,12 @@ export const ConfirmModal = ({
</div>
<DialogActions className={"flex-row justify-end gap-2.5"}>
<Button variant={"secondary"} size={"xs2"} disabled={busy} onClick={onCancel}>
<Button
variant={"secondary"}
size={"xs2"}
disabled={busy}
onClick={onCancel}
>
{resolvedCancel}
</Button>
<Button
@@ -20,7 +20,8 @@ const Overlay = forwardRef<ElementRef<typeof DialogPrimitive.Overlay>, OverlayPr
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
"bg-black/60",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
exitAnimation && "data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
exitAnimation &&
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
"duration-150 ease-out",
className,
)}
@@ -34,6 +35,8 @@ type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showClose?: boolean;
maxWidthClass?: string;
exitAnimation?: boolean;
srTitle?: string;
srDescription?: string;
};
export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>(
@@ -44,6 +47,8 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
showClose = true,
maxWidthClass = "max-w-md",
exitAnimation = false,
srTitle,
srDescription,
...props
},
ref,
@@ -70,8 +75,17 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
{...props}
>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
<DialogPrimitive.Title>
{srTitle ?? t("common.netbird")}
</DialogPrimitive.Title>
</VisuallyHidden>
{srDescription && (
<VisuallyHidden asChild>
<DialogPrimitive.Description>
{srDescription}
</DialogPrimitive.Description>
</VisuallyHidden>
)}
{children}
{showClose && (
<DialogPrimitive.Close
@@ -82,7 +96,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
)}
aria-label={t("common.close")}
>
<X className="h-4 w-4" />
<X className="h-4 w-4" aria-hidden="true" />
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
@@ -13,10 +13,17 @@ type DialogHeadingProps = {
children: ReactNode;
className?: string;
align?: DialogAlign;
id?: string;
};
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
<p
export const DialogHeading = ({
children,
className,
align = "center",
id,
}: DialogHeadingProps) => (
<h2
id={id}
className={cn(
"w-full text-base font-semibold text-nb-gray-50 select-none",
alignClass[align],
@@ -24,5 +31,5 @@ export const DialogHeading = ({ children, className, align = "center" }: DialogH
)}
>
{children}
</p>
</h2>
);
@@ -35,9 +35,7 @@ export const DaemonOutdatedOverlay = () => {
<p className={"text-base font-medium text-nb-gray-50"}>
{t("daemon.outdated.title")}
</p>
<p className={"text-sm text-nb-gray-300"}>
{t("daemon.outdated.description")}
</p>
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
</div>
<div className={"wails-no-draggable"}>
@@ -1,4 +1,5 @@
import { forwardRef, InputHTMLAttributes, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/cn";
@@ -8,16 +9,25 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
};
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
{ iconSize = 16, className, disabled, shortcut, ...props },
{ iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props },
ref,
) {
const { t } = useTranslation();
return (
<div className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}>
<SearchIcon size={iconSize} className={"text-nb-gray-300 shrink-0"} />
<div
role="search"
className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}
>
<SearchIcon
size={iconSize}
aria-hidden="true"
className={"text-nb-gray-300 shrink-0"}
/>
<input
ref={ref}
type={"text"}
type={"search"}
disabled={disabled}
aria-label={ariaLabel ?? props.placeholder ?? t("common.search")}
{...props}
className={cn(
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
@@ -28,6 +38,7 @@ export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchIn
/>
{shortcut && (
<span
aria-hidden="true"
className={cn(
"shrink-0 select-none",
"inline-flex items-center justify-center",
@@ -31,13 +31,21 @@ export default function FancyToggleSwitch({
labelClassName,
textWrapperClassName = "max-w-lg",
}: Readonly<Props>) {
const switchId = React.useId();
const descriptionId = React.useId();
const childrenRef = React.useRef<HTMLDivElement>(null);
const switchRef = React.useRef<HTMLButtonElement>(null);
if (loading) {
const shimmer =
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
return (
<div className={cn("inline-block text-left w-full", className)} aria-busy>
<div
role="status"
aria-busy="true"
aria-live="polite"
className={cn("inline-block text-left w-full", className)}
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>
@@ -51,6 +59,7 @@ export default function FancyToggleSwitch({
</div>
<div className={"mt-2 pr-1"}>
<div
aria-hidden="true"
className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"}
/>
</div>
@@ -62,26 +71,18 @@ export default function FancyToggleSwitch({
const fromChildren = (target: EventTarget | null) =>
target instanceof Node && childrenRef.current?.contains(target);
const handleToggle = (event: React.MouseEvent) => {
const handleClick = (event: React.MouseEvent) => {
if (disabled || fromChildren(event.target)) return;
onChange(!value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (disabled || fromChildren(event.target)) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onChange(!value);
}
const target = event.target as HTMLElement;
// Let the switch own its own click so focus + state stay together.
if (target.closest("button,input,a,[role=switch]")) return;
switchRef.current?.click();
switchRef.current?.focus();
};
return (
<div
onClick={handleToggle}
onKeyDown={handleKeyDown}
tabIndex={-1}
role={"switch"}
aria-checked={value}
onClick={handleClick}
className={cn(
"cursor-default transition-all duration-300 relative z-[1]",
"inline-block text-left w-full",
@@ -91,11 +92,24 @@ export default function FancyToggleSwitch({
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>{label}</Label>
<HelpText margin={false}>{helpText}</HelpText>
<Label as="div" className={labelClassName}>
<label htmlFor={switchId} className={"cursor-default"}>
{label}
</label>
</Label>
<HelpText margin={false}>
<span id={descriptionId}>{helpText}</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<ToggleSwitch checked={value} onCheckedChange={onChange} dataCy={dataCy} />
<ToggleSwitch
ref={switchRef}
id={switchId}
checked={value}
onCheckedChange={onChange}
dataCy={dataCy}
aria-describedby={helpText ? descriptionId : undefined}
/>
</div>
</div>
{children && value ? (
@@ -23,6 +23,8 @@ type Props = {
children: ReactNode;
className?: string;
disabled?: boolean;
"aria-label"?: string;
"aria-labelledby"?: string;
};
export const SwitchItemGroup = ({
@@ -31,6 +33,8 @@ export const SwitchItemGroup = ({
children,
className,
disabled = false,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
}: Props) => {
const layoutId = useId();
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
@@ -41,6 +45,8 @@ export const SwitchItemGroup = ({
value={value}
onValueChange={onChange}
disabled={disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn(
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1 overflow-hidden",
disabled && "opacity-50 pointer-events-none",
@@ -35,7 +35,8 @@ const switchVariants = cva("", {
],
},
"thumb-size": {
default: "h-5 w-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:translate-x-5",
default:
"h-5 w-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:translate-x-5",
small: "h-[14px] w-[14px] data-[state=unchecked]:translate-x-0 data-[state=checked]:translate-x-[17px]",
large: "h-[30px] w-[30px] data-[state=unchecked]:translate-x-[1px] data-[state=checked]:translate-x-[31px]",
},