From 985ebe3c38579e39054452c88954dee30a69f2b2 Mon Sep 17 00:00:00 2001 From: George Karr Date: Wed, 5 Aug 2026 16:01:21 -0500 Subject: [PATCH] Make dashboard 'Hosts enrolled' platform links keyboard accessible (#48214) (#49839) --- ...shboard-hosts-enrolled-keyboard-accessible | 1 + .../HostsEnrolledCard.tests.tsx | 83 ++++++++++++++++++- .../HostsEnrolledCard/HostsEnrolledCard.tsx | 31 +++++-- .../cards/HostsEnrolledCard/_styles.scss | 19 ++++- 4 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 changes/48214-dashboard-hosts-enrolled-keyboard-accessible 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; } }