Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions src/components/table/body/header/cell.js
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 || {}),
Expand Down Expand Up @@ -111,8 +146,8 @@ const BodyHeaderCell = ({
<LabelContainer
data-testid="netdata-table-header-cell-label-container"
alignItems="center"
cursor={column.getCanSort() ? "pointer" : "default"}
onClick={column.getCanSort() ? column.getToggleSortingHandler() : undefined}
cursor={canSort ? "pointer" : "default"}
onClick={canSort ? handleSortingClick : undefined}
padding={[0, 0, 0, !hasSubheaders || isSubheader ? 0 : 2]}
overflow="hidden"
width="100%"
Expand All @@ -121,16 +156,22 @@ const BodyHeaderCell = ({
{column.isPlaceholder ? null : (
<Label
as={column.columnDef.labelAs}
{...column.columnDef.labelProps}
{...labelProps}
role={labelRole ?? (canSort ? "button" : undefined)}
tabIndex={labelTabIndex ?? (headerTooltipContent || canSort ? 0 : undefined)}
aria-label={
labelAriaLabel ?? getAccessibleHeaderLabel(headerTooltipContent, canSort, sorting)
}
onKeyDown={canSort ? handleSortingKeyDown : labelOnKeyDown}
data-table-header-tooltip={headerTooltipContent}
sorting={column.getIsSorted()}
sortable={column.getCanSort()}
sorting={sorting}
sortable={canSort}
strong
>
{flexRender(column.columnDef.header, header.getContext())}
</Label>
)}
<Sorting sortable={column.getCanSort()} sorting={column.getIsSorted()} />
<Sorting sortable={canSort} sorting={sorting} />
<Info meta={meta} />
</LabelContainer>
<Filter column={column} testPrefix={testPrefix} index={index} />
Expand Down
3 changes: 3 additions & 0 deletions src/components/table/body/header/dragHandle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
>
<Icon name="hamburger" color="textLite" />
</HandleContainer>
Expand Down
1 change: 1 addition & 0 deletions src/components/table/body/header/resizeHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const ResizeHandler = ({ header, table, testPrefix = "" }) => {
return (
<Handler
ref={ref}
data-testid={`netdata-table-header-resize-handler-${column.id}${testPrefix}`}
border={{ side: "right", size: "1px", color: "borderSecondary" }}
_hover={{ border: { side: "right", size: "3px", color: "resizerLine" } }}
_active={{ border: { side: "right", size: "3px", color: "resizerLine" } }}
Expand Down
187 changes: 174 additions & 13 deletions src/components/table/headerOverflowTooltip.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react"
import { renderWithProviders, waitFor } from "testUtilities"
import { fireEvent, renderWithProviders, waitFor } from "testUtilities"
import Table from "./table"

const dataColumns = [
Expand All @@ -16,20 +16,181 @@ const dataColumns = [
},
]

it("publishes dedicated header overflow content without replacing column drag handles", async () => {
const { container } = renderWithProviders(
<Table data={[]} dataColumns={dataColumns} enableColumnReordering />
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 => <span>{content}</span>,
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(
<Table
data={[]}
dataColumns={dataColumns}
enableColumnReordering
enableResizing
enableSorting
onColumnSizingChange={onColumnSizingChange}
onSortingChange={onSortingChange}
/>
)

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(
<Table data={[]} dataColumns={dataColumns} overflowTooltip={overflowTooltip} />
)
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(
<Table
data={[]}
dataColumns={[
{
id: "visual",
accessorKey: "visual",
enableSorting: false,
header: <span>Visual header</span>,
},
]}
/>
)

expect(await findByText("Visual header")).not.toHaveAttribute("tabindex")
})

it("sorts focused headers with Enter and Space", async () => {
const onSortingChange = jest.fn()
const { getByRole } = renderWithProviders(
<Table
data={[{ dynamic: "value", literal: "value" }]}
dataColumns={dataColumns}
enableSorting
onSortingChange={onSortingChange}
/>
)
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(
<Table
data={[{ custom: "value" }]}
dataColumns={[
{
id: "custom",
accessorKey: "custom",
header: "Custom header",
labelProps: {
"aria-label": "Custom accessible header",
onKeyDown,
role: "columnheader",
tabIndex: -1,
},
},
]}
enableSorting
onSortingChange={onSortingChange}
/>
)
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()
})
Loading