From 3ab725f6dc65e2ba20583a39d28fb9758aa11f90 Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Mon, 13 Jul 2026 19:43:33 +0300 Subject: [PATCH] Make table header tooltips keyboard accessible --- src/components/table/body/header/cell.js | 55 +++++- .../table/body/header/dragHandle.js | 3 + .../table/body/header/resizeHandler.js | 1 + .../table/headerOverflowTooltip.test.js | 187 ++++++++++++++++-- 4 files changed, 226 insertions(+), 20 deletions(-) diff --git a/src/components/table/body/header/cell.js b/src/components/table/body/header/cell.js index 4db524c2..d5c4e9eb 100644 --- a/src/components/table/body/header/cell.js +++ b/src/components/table/body/header/cell.js @@ -10,7 +10,7 @@ import Sorting, { SortIconContainer } from "./sorting" import Info from "./info" import Filter from "./filter" import useSortableHeader from "./sortableHeader" -import DragHandle from "./dragHandle" +import DragHandle, { tableHeaderDragHandleSelector } from "./dragHandle" const Label = styled(Text)` width: 100%; @@ -44,6 +44,14 @@ export const getHeaderTooltipContent = columnDef => { return undefined } +const getAccessibleHeaderLabel = (content, canSort, sorting) => { + if (!content) return undefined + if (!canSort) return content + if (sorting === "asc") return `${content}, sorted ascending` + if (sorting === "desc") return `${content}, sorted descending` + return `${content}, sortable, not sorted` +} + const BodyHeaderCell = ({ header, table, @@ -74,6 +82,33 @@ const BodyHeaderCell = ({ : column.columnDef.meta const headerTooltipContent = getHeaderTooltipContent(column.columnDef) + const canSort = column.getCanSort() + const sorting = column.getIsSorted() + const toggleSorting = canSort ? column.getToggleSortingHandler() : undefined + const { + "aria-label": labelAriaLabel, + onKeyDown: labelOnKeyDown, + role: labelRole, + tabIndex: labelTabIndex, + ...labelProps + } = column.columnDef.labelProps || {} + + const handleSortingClick = event => { + if ( + event.defaultPrevented || + (event.target instanceof Element && event.target.closest(tableHeaderDragHandleSelector)) + ) + return + toggleSorting?.(event) + } + + const handleSortingKeyDown = event => { + labelOnKeyDown?.(event) + if (event.defaultPrevented) return + if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) return + event.preventDefault() + toggleSorting?.(event) + } const headStyles = { ...(tableMeta?.styles || {}), @@ -111,8 +146,8 @@ const BodyHeaderCell = ({ {flexRender(column.columnDef.header, header.getContext())} )} - + diff --git a/src/components/table/body/header/dragHandle.js b/src/components/table/body/header/dragHandle.js index d0bbcec7..9b568941 100644 --- a/src/components/table/body/header/dragHandle.js +++ b/src/components/table/body/header/dragHandle.js @@ -19,6 +19,8 @@ const HandleContainer = styled(Flex)` } ` +export const tableHeaderDragHandleSelector = "[data-table-header-drag-handle]" + export const DragHandle = ({ dragHandleProps, visible }) => { if (!dragHandleProps || Object.keys(dragHandleProps).length === 0) { return null @@ -32,6 +34,7 @@ export const DragHandle = ({ dragHandleProps, visible }) => { padding={[0, 1, 0, 0]} style={{ opacity: visible ? 1 : undefined }} className="drag-handle" + data-table-header-drag-handle > diff --git a/src/components/table/body/header/resizeHandler.js b/src/components/table/body/header/resizeHandler.js index a52243b8..49d5c64e 100644 --- a/src/components/table/body/header/resizeHandler.js +++ b/src/components/table/body/header/resizeHandler.js @@ -115,6 +115,7 @@ const ResizeHandler = ({ header, table, testPrefix = "" }) => { return ( { - const { container } = renderWithProviders( - +const setDimensions = ( + target, + { clientHeight = 16, clientWidth = 100, scrollHeight = 16, scrollWidth = 200 } = {} +) => { + Object.defineProperties(target, { + clientHeight: { configurable: true, value: clientHeight }, + clientWidth: { configurable: true, value: clientWidth }, + scrollHeight: { configurable: true, value: scrollHeight }, + scrollWidth: { configurable: true, value: scrollWidth }, + }) +} + +const elementIsOverflowing = element => + element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight + +const overflowTooltip = { + getContent: target => target.dataset.tableHeaderTooltip, + isOverflowing: target => + elementIsOverflowing(target) || [...target.querySelectorAll("*")].some(elementIsOverflowing), + renderContent: content => {content}, + selector: "[data-table-header-tooltip]", +} + +it("exposes truncated headers to keyboard focus without replacing drag or resize controls", async () => { + const onColumnSizingChange = jest.fn() + const onSortingChange = jest.fn() + const { container, getByRole, getByTestId } = renderWithProviders( +
+ ) + + const header = await waitFor(() => + getByRole("button", { name: "XYZ complete dynamic column name, sortable, not sorted" }) + ) + const tooltipTarget = container.querySelector( + '[data-table-header-tooltip="XYZ complete dynamic column name"]' ) + const dragHandle = header.parentElement.querySelector(".drag-handle") + const resizeHandler = getByTestId("netdata-table-header-resize-handler-dynamic") + + expect(header).toHaveAttribute("tabindex", "0") + expect(header).toHaveAttribute( + "aria-label", + "XYZ complete dynamic column name, sortable, not sorted" + ) + expect(tooltipTarget).toBeInTheDocument() + expect(tooltipTarget).not.toContainElement(dragHandle) + expect(dragHandle).toHaveAttribute("role", "button") + expect(dragHandle).toHaveAttribute("tabindex", "0") + + fireEvent.click(dragHandle) + fireEvent.keyDown(dragHandle, { key: "Enter" }) + expect(onSortingChange).not.toHaveBeenCalled() - await waitFor(() => { + fireEvent.doubleClick(resizeHandler) + expect(onColumnSizingChange).toHaveBeenCalledWith({ dynamic: 250 }) +}) + +it("opens a header tooltip on focus only when the label is truncated", async () => { + const { container, queryByText } = renderWithProviders( +
+ ) + await waitFor(() => expect( container.querySelector('[data-table-header-tooltip="XYZ complete dynamic column name"]') - ).toBeInTheDocument() - expect( - container.querySelector('[data-table-header-tooltip="Complete literal column name"]') - ).toBeInTheDocument() - expect(container.querySelectorAll(".drag-handle[role=button]")).toHaveLength(2) - expect(container.querySelectorAll("[data-table-header-tooltip] .drag-handle")).toHaveLength(0) - expect(container.querySelectorAll("[data-overflow-tooltip]")).toHaveLength(0) - }) + ).toHaveAttribute("tabindex", "0") + ) + const target = container.querySelector( + '[data-table-header-tooltip="XYZ complete dynamic column name"]' + ) + + setDimensions(target) + fireEvent.focus(target) + expect(queryByText("XYZ complete dynamic column name")).toBeInTheDocument() + + fireEvent.blur(target) + setDimensions(target, { scrollWidth: 100 }) + fireEvent.focus(target) + expect(queryByText("XYZ complete dynamic column name")).not.toBeInTheDocument() + + fireEvent.blur(target) + setDimensions(target) + fireEvent.mouseOver(target) + expect(queryByText("XYZ complete dynamic column name")).toBeInTheDocument() +}) + +it("does not add an inert tab stop when a React header has no tooltip content", async () => { + const { findByText } = renderWithProviders( +
Visual header, + }, + ]} + /> + ) + + expect(await findByText("Visual header")).not.toHaveAttribute("tabindex") +}) + +it("sorts focused headers with Enter and Space", async () => { + const onSortingChange = jest.fn() + const { getByRole } = renderWithProviders( +
+ ) + const header = await waitFor(() => + getByRole("button", { name: "XYZ complete dynamic column name, sortable, not sorted" }) + ) + + fireEvent.keyDown(header, { key: "Enter" }) + expect(onSortingChange).toHaveBeenLastCalledWith([{ desc: false, id: "dynamic" }]) + await waitFor(() => + expect(header).toHaveAttribute( + "aria-label", + "XYZ complete dynamic column name, sorted ascending" + ) + ) + + fireEvent.keyDown(header, { key: " " }) + expect(onSortingChange).toHaveBeenLastCalledWith([{ desc: true, id: "dynamic" }]) + await waitFor(() => + expect(header).toHaveAttribute( + "aria-label", + "XYZ complete dynamic column name, sorted descending" + ) + ) +}) + +it("preserves custom header keyboard behavior and accessible props", async () => { + const onSortingChange = jest.fn() + const onKeyDown = jest.fn(event => event.preventDefault()) + const { getByRole } = renderWithProviders( +
+ ) + const header = await waitFor(() => + getByRole("columnheader", { name: "Custom accessible header" }) + ) + + expect(header).toHaveAttribute("tabindex", "-1") + + fireEvent.keyDown(header, { key: "Enter" }) + + expect(onKeyDown).toHaveBeenCalledTimes(1) + expect(onSortingChange).not.toHaveBeenCalled() })