diff --git a/changes/48214-dashboard-hosts-enrolled-keyboard-accessible b/changes/48214-dashboard-hosts-enrolled-keyboard-accessible
new file mode 100644
index 0000000000..0510c2adf1
--- /dev/null
+++ b/changes/48214-dashboard-hosts-enrolled-keyboard-accessible
@@ -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").
diff --git a/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tests.tsx b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tests.tsx
index c91c926824..cd3731870d 100644
--- a/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tests.tsx
+++ b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tests.tsx
@@ -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(
+
+ );
+
+ 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(
+
+ );
+
+ // 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(
+
+ );
+
+ 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);
+ });
+ });
});
diff --git a/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tsx b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tsx
index 98dd93d68c..ca9b5d48a5 100644
--- a/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tsx
+++ b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/HostsEnrolledCard.tsx
@@ -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 ;
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) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onLabelClick(payload.index);
+ }
+ };
+
return (
- onLabelClick(payload.index) : undefined}
- >
+
onLabelClick(payload.index) : undefined}
+ onKeyDown={clickable ? handleKeyDown : undefined}
>
{payload.value}
diff --git a/frontend/pages/DashboardPage/cards/HostsEnrolledCard/_styles.scss b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/_styles.scss
index 8d0b8eb067..141fc83d17 100644
--- a/frontend/pages/DashboardPage/cards/HostsEnrolledCard/_styles.scss
+++ b/frontend/pages/DashboardPage/cards/HostsEnrolledCard/_styles.scss
@@ -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
- // and the chart surface itself is focusable when interactive.
+ // 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;
}
}