Make dashboard 'Hosts enrolled' platform links keyboard accessible (#48214) (#49839)

This commit is contained in:
George Karr
2026-08-05 16:01:21 -05:00
committed by GitHub
parent 40d58607bb
commit 985ebe3c38
4 changed files with 125 additions and 9 deletions
@@ -0,0 +1 @@
- Made the per-platform entries in the dashboard "Hosts enrolled" chart keyboard accessible: each platform with hosts is now focusable via Tab, activatable with Enter/Space, has a visible focus indicator, and exposes an accessible name (e.g. "macOS hosts").
@@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/no-empty-function, class-methods-use-this */
import React from "react";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { InjectedRouter } from "react-router";
import { ILabelSummary } from "interfaces/label";
import HostsEnrolledCard, { formatPercent } from "./HostsEnrolledCard";
@@ -43,6 +44,18 @@ class MockResizeObserver {
const noopRouter = ({ push: () => undefined } as unknown) as InjectedRouter;
// Built-in labels keyed to PLATFORM_NAME_TO_LABEL_NAME so the card can resolve a
// hosts-list link for each platform.
const builtInLabels: ILabelSummary[] = [
{ id: 10, name: "macOS", label_type: "builtin" },
{ id: 11, name: "MS Windows", label_type: "builtin" },
{ id: 12, name: "All Linux", label_type: "builtin" },
{ id: 13, name: "chrome", label_type: "builtin" },
{ id: 14, name: "iOS", label_type: "builtin" },
{ id: 15, name: "iPadOS", label_type: "builtin" },
{ id: 16, name: "Android", label_type: "builtin" },
];
const counts = {
darwin: 21925,
windows: 120,
@@ -117,4 +130,72 @@ describe("HostsEnrolledCard", () => {
expect(screen.getByText(label)).toBeInTheDocument();
});
});
// Regression: the per-platform labels must be keyboard-operable, not just
// mouse-clickable SVG text. See #48214.
describe("keyboard accessibility", () => {
it("exposes platforms with hosts as focusable, accessibly named buttons", () => {
render(
<HostsEnrolledCard
counts={counts}
totalHostCount={22070}
builtInLabels={builtInLabels}
router={noopRouter}
/>
);
const macButton = screen.getByRole("button", { name: "macOS hosts" });
expect(macButton).toBeInTheDocument();
// Reachable via Tab (tabindex 0).
expect(macButton).toHaveAttribute("tabindex", "0");
// A platform whose display label ("ChromeOS") differs from its built-in
// label name ("chrome") still resolves and becomes operable.
expect(
screen.getByRole("button", { name: "ChromeOS hosts" })
).toBeInTheDocument();
});
it("does not turn platforms with zero hosts into buttons", () => {
render(
<HostsEnrolledCard
counts={counts}
totalHostCount={22070}
builtInLabels={builtInLabels}
router={noopRouter}
/>
);
// iOS/iPadOS/Android have a count of 0, so they should stay plain text.
expect(
screen.queryByRole("button", { name: "iOS hosts" })
).not.toBeInTheDocument();
});
it("navigates to the platform's hosts list on Enter and Space", () => {
const push = jest.fn();
const router = ({ push } as unknown) as InjectedRouter;
render(
<HostsEnrolledCard
counts={counts}
totalHostCount={22070}
builtInLabels={builtInLabels}
currentTeamId={3}
router={router}
/>
);
const macButton = screen.getByRole("button", { name: "macOS hosts" });
fireEvent.keyDown(macButton, { key: "Enter" });
fireEvent.keyDown(macButton, { key: " " });
expect(push).toHaveBeenCalledTimes(2);
// Links to the macOS built-in label (id 10) while preserving the fleet.
const expectedPath = expect.stringMatching(/\/labels\/10.*fleet_id=3/);
expect(push).toHaveBeenNthCalledWith(1, expectedPath);
expect(push).toHaveBeenNthCalledWith(2, expectedPath);
});
});
});
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { InjectedRouter } from "react-router";
import classnames from "classnames";
import {
BarChart,
Bar,
@@ -119,6 +120,9 @@ interface IYAxisTickProps {
x?: number;
y?: number;
payload?: { value: string; index: number };
// recharts merges its own "recharts-cartesian-axis-tick-value" class in via
// cloneElement; forward it so recharts' internal tick measurement still works.
className?: string;
fontSize: number;
isClickable: (index: number) => boolean;
onLabelClick: (index: number) => void;
@@ -128,17 +132,27 @@ const ClickableYAxisTick = ({
x = 0,
y = 0,
payload,
className,
fontSize,
isClickable,
onLabelClick,
}: IYAxisTickProps): JSX.Element => {
if (!payload) return <g />;
const clickable = isClickable(payload.index);
// Make clickable platform labels real, keyboard-operable controls: focusable
// via Tab (tabIndex), announced as buttons, and activatable with Enter/Space
// in addition to a mouse click. They navigate programmatically (no href), so
// button semantics fit better than a link. See #48214.
const handleKeyDown = (event: React.KeyboardEvent<SVGTextElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onLabelClick(payload.index);
}
};
return (
<g
transform={`translate(${x},${y})`}
onClick={clickable ? () => onLabelClick(payload.index) : undefined}
>
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
@@ -146,7 +160,14 @@ const ClickableYAxisTick = ({
textAnchor="end"
fontSize={fontSize}
fontWeight="normal"
className={clickable ? `${baseClass}__tick--clickable` : undefined}
className={classnames(className, {
[`${baseClass}__tick--clickable`]: clickable,
})}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-label={clickable ? `${payload.value} hosts` : undefined}
onClick={clickable ? () => onLabelClick(payload.index) : undefined}
onKeyDown={clickable ? handleKeyDown : undefined}
>
{payload.value}
</text>
@@ -33,6 +33,17 @@
cursor: pointer;
}
// Clickable platform labels are keyboard-operable buttons (see #48214). Give
// them a visible focus indicator when reached via keyboard: an outline ring
// plus a darker label fill to further set the focused row apart from the
// muted inactive ticks. This must be excluded from the recharts outline
// suppression below so the ring actually renders.
&__tick--clickable:focus-visible {
outline: 1px solid $core-focused-outline;
outline-offset: 2px;
fill: $core-fleet-black;
}
&__tooltip {
background: $core-fleet-black;
color: $core-fleet-white;
@@ -59,13 +70,15 @@
// Suppress browser focus/click outlines on the chart wrapper, SVG surface,
// and individual bar rectangles. Recharts wraps each bar in a focusable
// <rect> and the chart surface itself is focusable when interactive.
// <rect> and the chart surface itself is focusable when interactive. Mouse
// focus (`:focus:not(:focus-visible)`) is also suppressed on the clickable
// ticks so click-and-hold doesn't flash the browser's default blue ring;
// keyboard focus keeps the Fleet ring from the `:focus-visible` rule above.
.recharts-wrapper,
.recharts-surface,
.recharts-bar-rectangle,
.recharts-rectangle,
.recharts-wrapper *:focus,
.recharts-wrapper *:focus-visible {
.recharts-wrapper *:focus:not(:focus-visible) {
outline: none !important;
}
}