Skip to content

Commit c6b9c39

Browse files
committed
AccessibleDataTable: paging, datum extraction
Refactor AccessibleDataTable to surface raw datum fields (not pixel coords), export extractAllRows for testing, and add pageable samples. Introduces datumToValues to filter/prune fields, PAGE_SIZE and a "Show more" button with styling, and resets visible state on dismiss. Change focus handling so the region only auto-expands when the region itself receives focus (prevent accidental expansion when focusing the inner button). Move AriaLiveTooltip live region outside role="img" wrappers in StreamGeo/Network/Ordinal/XY frames. Update unit tests and docs to reflect pageable rows, preserved raw data values, and the new focus/pagination behaviors.
1 parent 31ff0f0 commit c6b9c39

7 files changed

Lines changed: 269 additions & 178 deletions

File tree

docs/src/pages/features/AccessibilityPage.js

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -218,13 +218,18 @@ export default function AccessibilityPage() {
218218
<h3 id="data-summary">Data Summary</h3>
219219

220220
<p>
221-
Every chart includes a JIT data summary — a statistical overview plus 5
222-
sample rows, computed on demand (not on every render). Screen reader users
223-
can activate a "View data summary" button inside the chart; sighted users
224-
can trigger it from the ChartContainer toolbar. Either way, the summary
225-
describes the data shape the way <code>.describe()</code> and{" "}
226-
<code>.head()</code> do in pandas: field ranges, means, unique categories,
227-
then a small sample table.
221+
Every chart includes a JIT data summary — a statistical overview plus a
222+
sample of rows (5 to start), computed on demand (not on every render).
223+
Screen reader users can activate a "View data summary" button inside the
224+
chart; sighted users can trigger it from the ChartContainer toolbar.
225+
Either way, the summary describes the data shape the way{" "}
226+
<code>.describe()</code> and <code>.head()</code> do in pandas: field
227+
ranges, means, unique categories, then a sample table. The table shows
228+
the <strong>actual data values</strong> (your accessor fields, e.g.{" "}
229+
<code>month</code> / <code>sales</code>), not pixel coordinates, and a{" "}
230+
<strong>"Show more rows"</strong> button pages through to the full dataset
231+
in bounded chunks — so a 50k-row chart never instantiates a giant table at
232+
once, but the data is never hidden either.
228233
</p>
229234

230235
<p>
@@ -246,8 +251,8 @@ export default function AccessibilityPage() {
246251
</ChartContainer>
247252
248253
// The summary shows:
249-
// "72 data points. x: 1 to 12, mean 6.5. y: 12000 to 27000, mean 18500."
250-
// + a 5-row sample table
254+
// "72 data points. month: 1 to 12, mean 6.5. sales: 12000 to 27000, mean 18500."
255+
// + a sample table of the real data values, pageable to all 72 rows
251256
252257
// For screen readers, the summary is always available via a hidden button
253258
// (accessibleTable={true} by default). The ChartContainer action just
@@ -472,7 +477,7 @@ const result = diagnoseConfig("LineChart", {
472477
["title", "string | ReactNode", "-", "Visible heading; fallback aria-label when description is not set"],
473478
["description", "string", "-", "Overrides the auto-generated aria-label with a detailed description"],
474479
["summary", "string", "-", "Screen-reader-only note (role=\"note\") for trends or key takeaways"],
475-
["accessibleTable", "boolean", "true", "Enable JIT data summary (stats + 5 sample rows) for screen readers"],
480+
["accessibleTable", "boolean", "true", "Enable JIT data summary (stats + pageable sample rows) for screen readers"],
476481
["actions.dataSummary", "boolean", "false", "ChartContainer: toolbar button to show data summary visibly"],
477482
].map(([prop, type, def, desc], i) => (
478483
<tr key={i} style={{ borderBottom: "1px solid var(--surface-3)" }}>

src/components/stream/AccessibleDataTable.test.tsx

Lines changed: 132 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
NetworkAccessibleDataTable,
66
computeCanvasAriaLabel,
77
computeNetworkAriaLabel,
8+
extractAllRows,
89
} from "./AccessibleDataTable"
910
import type { Datum } from "../charts/shared/datumTypes"
1011

@@ -168,154 +169,164 @@ describe("AccessibleDataTable styling hooks", () => {
168169
})
169170
})
170171

171-
// ── extractAllRows logic (tested via internal behavior) ─────────────────
172-
173-
// Since extractAllRows is not exported, we test it indirectly by exercising
174-
// the same data shapes that would flow through AccessibleDataTable.
175-
// We test the extraction patterns inline here.
176-
177-
describe("extractAllRows — data shape resilience", () => {
178-
// We recreate the extraction logic to test it in isolation
179-
function extractRow(node: any): any | null {
180-
switch (node.type) {
181-
case "point":
182-
return { label: "Point", values: { x: node.x, y: node.y } }
183-
case "line": {
184-
const path = node.path
185-
const data = Array.isArray(node.datum) ? node.datum : []
186-
if (!path) return null
187-
const rows = []
188-
for (let i = 0; i < path.length && i < data.length; i++) {
189-
rows.push({ label: "Line point", values: { x: path[i][0], y: path[i][1] } })
190-
}
191-
return rows
192-
}
193-
case "rect": {
194-
const datum = node.datum ?? {}
195-
const category = datum.category ?? node.group ?? ""
196-
const rawValue = datum.value ?? datum.__aggregateValue ?? datum.total
197-
return { label: "Bar", values: { category, value: rawValue ?? "" } }
198-
}
199-
case "wedge":
200-
return {
201-
label: "Wedge",
202-
values: {
203-
category: node.datum?.category ?? node.datum?.label ?? "",
204-
value: node.datum?.value ?? "",
205-
},
206-
}
207-
case "circle":
208-
return {
209-
label: "Node",
210-
values: { id: node.datum?.id || "", x: node.cx ?? node.x, y: node.cy ?? node.y },
211-
}
212-
default:
213-
return null
214-
}
215-
}
216-
217-
it("handles point with undefined x/y", () => {
218-
const row = extractRow({ type: "point" })
219-
expect(row).toEqual({ label: "Point", values: { x: undefined, y: undefined } })
220-
})
172+
// ── interaction fixes (issue #971) ──────────────────────────────────────
221173

222-
it("handles point with NaN values", () => {
223-
const row = extractRow({ type: "point", x: NaN, y: NaN })
224-
expect(row).toEqual({ label: "Point", values: { x: NaN, y: NaN } })
225-
})
226-
227-
it("handles point with Infinity", () => {
228-
const row = extractRow({ type: "point", x: Infinity, y: -Infinity })
229-
expect(row!.values.x).toBe(Infinity)
230-
expect(row!.values.y).toBe(-Infinity)
231-
})
174+
describe("AccessibleDataTable — focus & pagination", () => {
175+
const lineScene = (n: number) => [
176+
{
177+
type: "line",
178+
path: Array.from({ length: n }, (_, i) => [i, i]),
179+
datum: Array.from({ length: n }, (_, i) => ({ month: i + 1, sales: 100 * (i + 1) })),
180+
},
181+
]
232182

233-
it("handles point with string coordinates (mistyped data)", () => {
234-
const row = extractRow({ type: "point", x: "hello", y: "world" })
235-
expect(row!.values.x).toBe("hello")
236-
expect(row!.values.y).toBe("world")
183+
it("does NOT auto-expand when focus bubbles up from the trigger button", () => {
184+
render(<AccessibleDataTable tableId="t1" chartType="line chart" scene={lineScene(3)} />)
185+
// Focusing the inner button (target !== region container) must not expand.
186+
fireEvent.focus(screen.getByRole("button", { name: /view data summary/i }))
187+
expect(screen.queryByRole("table")).toBeNull()
237188
})
238189

239-
it("handles line with null path", () => {
240-
const row = extractRow({ type: "line", path: null, datum: [] })
241-
expect(row).toBeNull()
190+
it("auto-expands when the region container itself receives focus (skip-link path)", () => {
191+
render(<AccessibleDataTable tableId="t2" chartType="line chart" scene={lineScene(3)} />)
192+
const region = screen.getByRole("region", { name: /data summary for line chart/i })
193+
fireEvent.focus(region) // target === currentTarget
194+
expect(screen.getByRole("table")).toBeInTheDocument()
242195
})
243196

244-
it("handles line with undefined path", () => {
245-
const row = extractRow({ type: "line", datum: [] })
246-
expect(row).toBeNull()
247-
})
197+
it("pages through rows beyond the initial sample via Show more", () => {
198+
render(<AccessibleDataTable tableId="t3" chartType="line chart" scene={lineScene(40)} />)
199+
fireEvent.click(screen.getByRole("button", { name: /view data summary/i }))
248200

249-
it("handles line with empty path/datum arrays", () => {
250-
const rows = extractRow({ type: "line", path: [], datum: [] })
251-
expect(rows).toEqual([])
252-
})
201+
// Initial sample is 5 rows (+1 header row).
202+
expect(screen.getAllByRole("row")).toHaveLength(5 + 1)
203+
expect(screen.getByText(/first 5 of 40 data points/i)).toBeInTheDocument()
253204

254-
it("handles line where datum is not an array", () => {
255-
const rows = extractRow({ type: "line", path: [[0, 0], [1, 1]], datum: "not-an-array" })
256-
expect(rows).toEqual([]) // data becomes [], so loop never runs
257-
})
205+
fireEvent.click(screen.getByRole("button", { name: /show .* more rows/i }))
206+
expect(screen.getAllByRole("row")).toHaveLength(30 + 1) // 5 + 25 page
258207

259-
it("handles line with mismatched path/datum lengths", () => {
260-
const rows = extractRow({
261-
type: "line",
262-
path: [[0, 0], [1, 1], [2, 2], [3, 3]],
263-
datum: [{ x: 0 }, { x: 1 }], // only 2 datums for 4 path points
264-
})
265-
expect(rows).toHaveLength(2) // min(4, 2)
266-
})
267-
268-
it("handles rect with no datum at all", () => {
269-
const row = extractRow({ type: "rect" })
270-
expect(row).toEqual({ label: "Bar", values: { category: "", value: "" } })
208+
fireEvent.click(screen.getByRole("button", { name: /show .* more rows/i }))
209+
expect(screen.getAllByRole("row")).toHaveLength(40 + 1) // capped at total
210+
expect(screen.getByText(/all 40 data points/i)).toBeInTheDocument()
211+
// No further "Show more" once everything is shown.
212+
expect(screen.queryByRole("button", { name: /show .* more/i })).toBeNull()
271213
})
214+
})
272215

273-
it("handles rect where datum is a primitive", () => {
274-
const row = extractRow({ type: "rect", datum: 42 })
275-
// datum ?? {} → 42, then 42.category → undefined, node.group → undefined
276-
expect(row!.label).toBe("Bar")
216+
// ── extractAllRows logic ────────────────────────────────────────────────
217+
218+
describe("extractAllRows — surfaces raw data, not pixels", () => {
219+
it("emits a scatter point's raw datum fields, not pixel x/y", () => {
220+
// The scene node carries pixel x/y for rendering; the table must show data.
221+
const rows = extractAllRows([
222+
{ type: "point", x: 412, y: 88, datum: { month: 1, sales: 4200 } },
223+
])
224+
expect(rows).toEqual([{ label: "Point", values: { month: 1, sales: 4200 } }])
225+
})
226+
227+
it("emits each line vertex from the datum array (data, not path pixels)", () => {
228+
const rows = extractAllRows([
229+
{
230+
type: "line",
231+
path: [[0, 0], [50, 50]], // pixel path — must be ignored
232+
datum: [{ month: 1, sales: 4200 }, { month: 2, sales: 5100 }],
233+
},
234+
])
235+
expect(rows).toEqual([
236+
{ label: "Line point", values: { month: 1, sales: 4200 } },
237+
{ label: "Line point", values: { month: 2, sales: 5100 } },
238+
])
239+
})
240+
241+
it("emits area vertices from the datum array", () => {
242+
const rows = extractAllRows([
243+
{ type: "area", topPath: [[0, 0]], datum: [{ x: 1, y: 2 }] },
244+
])
245+
expect(rows).toEqual([{ label: "Area point", values: { x: 1, y: 2 } }])
246+
})
247+
248+
it("skips redundant point nodes when a series node carries the same data", () => {
249+
// showPoints=true emits both a line node and per-point nodes; the points
250+
// are decorative duplicates and must not double-count.
251+
const rows = extractAllRows([
252+
{ type: "line", path: [[0, 0]], datum: [{ month: 1, sales: 4200 }] },
253+
{ type: "point", x: 412, y: 88, datum: { month: 1, sales: 4200 } },
254+
])
255+
expect(rows).toEqual([{ label: "Line point", values: { month: 1, sales: 4200 } }])
256+
})
257+
258+
it("emits a candlestick's raw OHLC datum, not undefined node fields", () => {
259+
// The node only carries openY/closeY pixels — node.open etc. don't exist.
260+
const rows = extractAllRows([
261+
{
262+
type: "candlestick",
263+
x: 100, openY: 50, closeY: 20, highY: 10, lowY: 60,
264+
datum: { date: "2024-01-01", open: 10, high: 15, low: 8, close: 12 },
265+
},
266+
])
267+
expect(rows[0].values).toEqual({ date: "2024-01-01", open: 10, high: 15, low: 8, close: 12 })
268+
})
269+
270+
it("falls back to the rendered cell value when a heatcell datum omits it", () => {
271+
const rows = extractAllRows([
272+
{ type: "heatcell", x: 5, y: 9, value: 42, datum: { row: "A", col: "B" } },
273+
])
274+
expect(rows[0].values).toEqual({ row: "A", col: "B", value: 42 })
275+
})
276+
277+
it("skips synthetic underscore-prefixed keys", () => {
278+
const rows = extractAllRows([
279+
{ type: "point", x: 1, y: 2, datum: { month: 1, _transitionKey: "k", _decayOpacity: 0.5 } },
280+
])
281+
expect(rows[0].values).toEqual({ month: 1 })
282+
})
283+
284+
it("preserves a falsy-but-valid 0 value", () => {
285+
const rows = extractAllRows([
286+
{ type: "point", x: 1, y: 2, datum: { month: 0, sales: 4200 } },
287+
])
288+
expect(rows[0].values).toEqual({ month: 0, sales: 4200 })
277289
})
290+
})
278291

279-
it("handles rect with __aggregateValue fallback", () => {
280-
const row = extractRow({ type: "rect", datum: { __aggregateValue: 99 } })
281-
expect(row!.values.value).toBe(99)
292+
describe("extractAllRows — data shape resilience (never throws)", () => {
293+
it("returns [] for a non-array scene", () => {
294+
expect(extractAllRows(null as any)).toEqual([])
295+
expect(extractAllRows("nope" as any)).toEqual([])
282296
})
283297

284-
it("handles rect with total fallback", () => {
285-
const row = extractRow({ type: "rect", datum: { total: 50 } })
286-
expect(row!.values.value).toBe(50)
298+
it("skips nodes with null datum", () => {
299+
expect(extractAllRows([{ type: "point", datum: null }])).toEqual([])
287300
})
288301

289-
it("handles wedge with no datum", () => {
290-
const row = extractRow({ type: "wedge" })
291-
expect(row).toEqual({ label: "Wedge", values: { category: "", value: "" } })
302+
it("handles a point with no datum (empty values, no throw)", () => {
303+
const rows = extractAllRows([{ type: "point", x: NaN, y: NaN }])
304+
expect(rows).toEqual([{ label: "Point", values: {} }])
292305
})
293306

294-
it("handles wedge where datum.category is 0 (falsy but valid)", () => {
295-
const row = extractRow({ type: "wedge", datum: { category: 0, value: 100 } })
296-
// ?? preserves 0 (unlike ||)
297-
expect(row!.values.category).toBe(0)
298-
expect(row!.values.value).toBe(100)
307+
it("handles a line whose datum is not an array", () => {
308+
expect(extractAllRows([{ type: "line", path: [[0, 0]], datum: "not-an-array" }])).toEqual([])
299309
})
300310

301-
it("handles circle with cx/cy", () => {
302-
const row = extractRow({ type: "circle", cx: 10, cy: 20, datum: { id: "a" } })
303-
expect(row!.values).toEqual({ id: "a", x: 10, y: 20 })
311+
it("drops non-finite and non-primitive datum fields", () => {
312+
const rows = extractAllRows([
313+
{ type: "point", x: 1, y: 2, datum: { a: Infinity, b: NaN, c: { nested: 1 }, d: 5 } },
314+
])
315+
expect(rows[0].values).toEqual({ d: 5 })
304316
})
305317

306-
it("handles circle with x/y fallback", () => {
307-
const row = extractRow({ type: "circle", x: 5, y: 15, datum: { id: "b" } })
308-
expect(row!.values).toEqual({ id: "b", x: 5, y: 15 })
318+
it("keeps the rect aggregate-value fallbacks", () => {
319+
expect(extractAllRows([{ type: "rect", datum: { __aggregateValue: 99 } }])[0].values.value).toBe(99)
320+
expect(extractAllRows([{ type: "rect", datum: { total: 50 } }])[0].values.value).toBe(50)
309321
})
310322

311-
it("handles circle with no datum", () => {
312-
const row = extractRow({ type: "circle", cx: 0, cy: 0 })
313-
expect(row!.values).toEqual({ id: "", x: 0, y: 0 })
323+
it("preserves a wedge category of 0", () => {
324+
const rows = extractAllRows([{ type: "wedge", datum: { category: 0, value: 100 } }])
325+
expect(rows[0].values).toEqual({ category: 0, value: 100 })
314326
})
315327

316-
it("handles unknown type gracefully", () => {
317-
const row = extractRow({ type: "hologram" })
318-
expect(row).toBeNull()
328+
it("skips unknown node types", () => {
329+
expect(extractAllRows([{ type: "hologram", datum: { a: 1 } }])).toEqual([])
319330
})
320331
})
321332

0 commit comments

Comments
 (0)