mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 06:09:07 +02:00
add a11y keys
This commit is contained in:
@@ -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]",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user