This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user