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()
})