@vanduo-oss/vd3-cbun 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -129,9 +129,22 @@ function normalizeMargin(value, fallback = DEFAULT_MARGIN) {
129
129
  left: Number(value?.left ?? fallback.left)
130
130
  };
131
131
  }
132
+ function dataTableContribution(target) {
133
+ if (!isElement(target) || typeof target.querySelectorAll !== "function") return 0;
134
+ let total = 0;
135
+ target.querySelectorAll(".vd-chart-data-table:not(.vd-chart-sr-only)").forEach((table) => {
136
+ const style = typeof getComputedStyle === "function" ? getComputedStyle(table) : null;
137
+ const marginTop = style ? parseFloat(style.marginTop) || 0 : 0;
138
+ const marginBottom = style ? parseFloat(style.marginBottom) || 0 : 0;
139
+ total += (table.offsetHeight || 0) + marginTop + marginBottom;
140
+ });
141
+ return total;
142
+ }
132
143
  function measureTarget(target, options) {
133
144
  const width = Number(options.width) || Math.round(target.clientWidth) || DEFAULT_WIDTH;
134
- const height = Number(options.height) || Math.round(target.clientHeight) || DEFAULT_HEIGHT;
145
+ const targetHeight = Math.round(Number(target.clientHeight));
146
+ const availableHeight = Math.max(0, targetHeight - dataTableContribution(target));
147
+ const height = Number(options.height) || (targetHeight > 0 ? availableHeight : DEFAULT_HEIGHT);
135
148
  return {
136
149
  width: Math.max(160, width),
137
150
  height: Math.max(140, height)
@@ -444,6 +457,74 @@ function makeInteractive(mark, hasClick, hasTooltip) {
444
457
  mark.setAttribute("focusable", "true");
445
458
  }
446
459
  }
460
+ function setupKeyboardNavigation(svg, markGroup) {
461
+ svg.addEventListener("keydown", (event) => {
462
+ const active = document.activeElement;
463
+ if (!active || !markGroup.contains(active)) return;
464
+ const marks = Array.from(markGroup.querySelectorAll('[tabindex="0"]'));
465
+ if (!marks.length) return;
466
+ const currentIndex = marks.indexOf(active);
467
+ if (currentIndex === -1) return;
468
+ let targetIndex = -1;
469
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
470
+ event.preventDefault();
471
+ targetIndex = (currentIndex + 1) % marks.length;
472
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
473
+ event.preventDefault();
474
+ targetIndex = (currentIndex - 1 + marks.length) % marks.length;
475
+ } else if (event.key === "Home") {
476
+ event.preventDefault();
477
+ targetIndex = 0;
478
+ } else if (event.key === "End") {
479
+ event.preventDefault();
480
+ targetIndex = marks.length - 1;
481
+ } else if (event.key === "Escape") {
482
+ event.preventDefault();
483
+ active.blur();
484
+ }
485
+ if (targetIndex !== -1 && marks[targetIndex]) {
486
+ marks[targetIndex].focus();
487
+ }
488
+ });
489
+ }
490
+ function renderDataTable(instance, tableData) {
491
+ const { target, options } = instance;
492
+ if (options.dataTable === false) return;
493
+ if (!tableData || !tableData.rows || !tableData.rows.length) return;
494
+ const table = document.createElement("table");
495
+ table.className = "vd-chart-data-table";
496
+ if (options.dataTable !== "visible") {
497
+ table.classList.add("vd-chart-sr-only");
498
+ }
499
+ const caption = document.createElement("caption");
500
+ caption.textContent = tableData.caption || options.title || options.ariaLabel || `${instance.kind} chart data`;
501
+ table.appendChild(caption);
502
+ if (tableData.headers && tableData.headers.length) {
503
+ const thead = document.createElement("thead");
504
+ const headerRow = document.createElement("tr");
505
+ tableData.headers.forEach((h2) => {
506
+ const th = document.createElement("th");
507
+ th.setAttribute("scope", "col");
508
+ th.textContent = String(h2);
509
+ headerRow.appendChild(th);
510
+ });
511
+ thead.appendChild(headerRow);
512
+ table.appendChild(thead);
513
+ }
514
+ const tbody = document.createElement("tbody");
515
+ tableData.rows.forEach((row) => {
516
+ const tr = document.createElement("tr");
517
+ row.forEach((cell, idx) => {
518
+ const el = idx === 0 ? document.createElement("th") : document.createElement("td");
519
+ if (idx === 0) el.setAttribute("scope", "row");
520
+ el.textContent = cell == null ? "" : String(cell);
521
+ tr.appendChild(el);
522
+ });
523
+ tbody.appendChild(tr);
524
+ });
525
+ table.appendChild(tbody);
526
+ target.appendChild(table);
527
+ }
447
528
  function createSvgShell(instance) {
448
529
  const { target, options } = instance;
449
530
  const size = measureTarget(target, options);
@@ -460,7 +541,8 @@ function createSvgShell(instance) {
460
541
  width: size.width,
461
542
  height: size.height,
462
543
  viewBox: `0 0 ${size.width} ${size.height}`,
463
- role: "img"
544
+ role: options.role || "graphics-document document",
545
+ "aria-roledescription": options.ariaRoleDescription || `${instance.kind} chart`
464
546
  });
465
547
  const titleId = nextId("vd-chart-title");
466
548
  const descId = nextId("vd-chart-desc");
@@ -531,7 +613,7 @@ function drawAxisLine(svg, x1, y1, x2, y2, theme) {
531
613
  }
532
614
  function drawCartesianAxes(svg, config) {
533
615
  const { plot, xScale, yScale, xTicks, yTicks, theme, options, categoricalX } = config;
534
- const axisGroup = append(svg, svgEl("g", { class: "vd-chart-axes" }));
616
+ const axisGroup = append(svg, svgEl("g", { class: "vd-chart-axes", "aria-hidden": "true" }));
535
617
  yTicks.forEach((tick) => {
536
618
  const y = yScale(tick);
537
619
  if (!isFiniteNumber(y)) return;
@@ -774,7 +856,14 @@ function drawDataLabel(svg, x, y, value, cfg, theme, anchor = "middle") {
774
856
  function drawAnnotations(svg, options, plot, xScale, yScale, theme) {
775
857
  const annotations = toArray(options.annotations);
776
858
  if (!annotations.length) return;
777
- const group = append(svg, svgEl("g", { class: "vd-chart-annotations" }));
859
+ const group = append(
860
+ svg,
861
+ svgEl("g", {
862
+ class: "vd-chart-annotations",
863
+ role: "graphics-object",
864
+ "aria-roledescription": "annotations"
865
+ })
866
+ );
778
867
  annotations.forEach((ann) => {
779
868
  const color = ann.color || theme.mutedTextColor;
780
869
  const dash = ann.dash === false ? null : "4 3";
@@ -894,7 +983,14 @@ function renderBarChart(instance) {
894
983
  });
895
984
  drawAnnotations(svg, options, plot, xScale, yScale, theme);
896
985
  const labels = dataLabelConfig(options);
897
- const markGroup = append(svg, svgEl("g", { class: "vd-chart-marks vd-chart-bars" }));
986
+ const markGroup = append(
987
+ svg,
988
+ svgEl("g", {
989
+ class: "vd-chart-marks vd-chart-bars",
990
+ role: "graphics-object",
991
+ "aria-roledescription": "data points"
992
+ })
993
+ );
898
994
  const baseline = yScale(Math.min(Math.max(0, yDomain[0]), yDomain[1]));
899
995
  rows.forEach((row) => {
900
996
  const x = xScale(row.x);
@@ -912,6 +1008,7 @@ function renderBarChart(instance) {
912
1008
  rx: 3,
913
1009
  fill,
914
1010
  role: "graphics-symbol",
1011
+ "aria-roledescription": "bar",
915
1012
  "aria-label": `${formatCategory(row.x)}: ${formatNumber(row.y)}`
916
1013
  });
917
1014
  makeInteractive(rect, typeof options.onBarClick === "function", options.tooltip !== false);
@@ -933,6 +1030,12 @@ function renderBarChart(instance) {
933
1030
  if (labels) drawDataLabel(svg, x + xScale.bandwidth() / 2, rectY - 4, row.y, labels, theme);
934
1031
  append(markGroup, rect);
935
1032
  });
1033
+ setupKeyboardNavigation(svg, markGroup);
1034
+ renderDataTable(instance, {
1035
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1036
+ headers: [options.xAxis?.label || "Category", options.yAxis?.label || "Value"],
1037
+ rows: rows.map((r) => [formatCategory(r.x), formatNumber(r.y)])
1038
+ });
936
1039
  if (options.legend && color && color.scale) {
937
1040
  renderTopLegend(
938
1041
  svg,
@@ -986,7 +1089,14 @@ function renderMultiBarChart(instance) {
986
1089
  });
987
1090
  drawAnnotations(svg, options, plot, xScale, yScale, theme);
988
1091
  const labels = dataLabelConfig(options);
989
- const markGroup = append(svg, svgEl("g", { class: "vd-chart-marks vd-chart-bars" }));
1092
+ const markGroup = append(
1093
+ svg,
1094
+ svgEl("g", {
1095
+ class: "vd-chart-marks vd-chart-bars",
1096
+ role: "graphics-object",
1097
+ "aria-roledescription": "grouped bars"
1098
+ })
1099
+ );
990
1100
  const baseline = yScale(Math.min(Math.max(0, yDomain[0]), yDomain[1]));
991
1101
  seriesList.forEach((series) => {
992
1102
  const fill = seriesColor(series, theme);
@@ -1006,6 +1116,7 @@ function renderMultiBarChart(instance) {
1006
1116
  rx: 3,
1007
1117
  fill,
1008
1118
  role: "graphics-symbol",
1119
+ "aria-roledescription": "bar",
1009
1120
  "aria-label": `${series.name} \u2014 ${formatCategory(row.x)}: ${formatNumber(row.y)}`
1010
1121
  });
1011
1122
  makeInteractive(rect, typeof options.onBarClick === "function", options.tooltip !== false);
@@ -1038,6 +1149,20 @@ function renderMultiBarChart(instance) {
1038
1149
  append(markGroup, rect);
1039
1150
  });
1040
1151
  });
1152
+ setupKeyboardNavigation(svg, markGroup);
1153
+ const tableRows = categories.map((cat) => {
1154
+ const rowCells = [cat];
1155
+ seriesList.forEach((s) => {
1156
+ const match = s.rows.find((r) => String(r.x) === cat);
1157
+ rowCells.push(match && isFiniteNumber(match.y) ? formatNumber(match.y) : "");
1158
+ });
1159
+ return rowCells;
1160
+ });
1161
+ renderDataTable(instance, {
1162
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1163
+ headers: [options.xAxis?.label || "Category", ...seriesList.map((s) => s.name)],
1164
+ rows: tableRows
1165
+ });
1041
1166
  if (options.legend !== false) {
1042
1167
  renderTopLegend(
1043
1168
  svg,
@@ -1098,7 +1223,14 @@ function renderLineLikeChart(instance, mode) {
1098
1223
  });
1099
1224
  drawAnnotations(svg, options, plot, xInfo.scale, yScale, theme);
1100
1225
  const labels = dataLabelConfig(options);
1101
- const markGroup = append(svg, svgEl("g", { class: `vd-chart-marks vd-chart-${mode}` }));
1226
+ const markGroup = append(
1227
+ svg,
1228
+ svgEl("g", {
1229
+ class: `vd-chart-marks vd-chart-${mode}`,
1230
+ role: "graphics-object",
1231
+ "aria-roledescription": `${mode} series`
1232
+ })
1233
+ );
1102
1234
  if (mode === "area") {
1103
1235
  const baseline = yScale(Math.max(0, yDomain[0]));
1104
1236
  append(
@@ -1135,6 +1267,7 @@ function renderLineLikeChart(instance, mode) {
1135
1267
  stroke: color,
1136
1268
  "stroke-width": 2,
1137
1269
  role: "graphics-symbol",
1270
+ "aria-roledescription": "data point",
1138
1271
  "aria-label": `${formatCategory(point.xValue)}: ${formatNumber(point.yValue)}`
1139
1272
  });
1140
1273
  makeInteractive(
@@ -1164,6 +1297,12 @@ function renderLineLikeChart(instance, mode) {
1164
1297
  (point) => drawDataLabel(svg, point.x, point.y - 8, point.yValue, labels, theme)
1165
1298
  );
1166
1299
  }
1300
+ setupKeyboardNavigation(svg, markGroup);
1301
+ renderDataTable(instance, {
1302
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1303
+ headers: [options.xAxis?.label || "X", options.yAxis?.label || "Y"],
1304
+ rows: points.map((p) => [formatCategory(p.xValue), formatNumber(p.yValue)])
1305
+ });
1167
1306
  }
1168
1307
  function renderMultiLineChart(instance, mode) {
1169
1308
  const shell = createSvgShell(instance);
@@ -1199,7 +1338,14 @@ function renderMultiLineChart(instance, mode) {
1199
1338
  });
1200
1339
  drawAnnotations(svg, options, plot, xInfo.scale, yScale, theme);
1201
1340
  const labels = dataLabelConfig(options);
1202
- const markGroup = append(svg, svgEl("g", { class: `vd-chart-marks vd-chart-${mode}` }));
1341
+ const markGroup = append(
1342
+ svg,
1343
+ svgEl("g", {
1344
+ class: `vd-chart-marks vd-chart-${mode}`,
1345
+ role: "graphics-object",
1346
+ "aria-roledescription": `multi-series ${mode}`
1347
+ })
1348
+ );
1203
1349
  const baseline = yScale(Math.max(0, yDomain[0]));
1204
1350
  seriesList.forEach((series) => {
1205
1351
  const stroke = seriesColor(series, theme);
@@ -1246,6 +1392,7 @@ function renderMultiLineChart(instance, mode) {
1246
1392
  stroke,
1247
1393
  "stroke-width": 2,
1248
1394
  role: "graphics-symbol",
1395
+ "aria-roledescription": "data point",
1249
1396
  "aria-label": `${series.name} ${formatCategory(point.xValue)}: ${formatNumber(point.yValue)}`
1250
1397
  });
1251
1398
  makeInteractive(
@@ -1278,6 +1425,31 @@ function renderMultiLineChart(instance, mode) {
1278
1425
  );
1279
1426
  }
1280
1427
  });
1428
+ setupKeyboardNavigation(svg, markGroup);
1429
+ const xKey = (value) => String(xInfo.mapValue(value));
1430
+ const uniqueX = /* @__PURE__ */ new Map();
1431
+ for (const row of allRows) {
1432
+ const key = xKey(row.x);
1433
+ if (!uniqueX.has(key)) uniqueX.set(key, row.x);
1434
+ }
1435
+ const seriesValues = seriesList.map((s) => {
1436
+ const byX = /* @__PURE__ */ new Map();
1437
+ for (const row of s.rows) byX.set(xKey(row.x), row.y);
1438
+ return byX;
1439
+ });
1440
+ const tableRows = [...uniqueX.entries()].map(([key, xVal]) => {
1441
+ const rowCells = [formatCategory(xVal)];
1442
+ seriesValues.forEach((byX) => {
1443
+ const y = byX.get(key);
1444
+ rowCells.push(isFiniteNumber(y) ? formatNumber(y) : "");
1445
+ });
1446
+ return rowCells;
1447
+ });
1448
+ renderDataTable(instance, {
1449
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1450
+ headers: [options.xAxis?.label || "X", ...seriesList.map((s) => s.name)],
1451
+ rows: tableRows
1452
+ });
1281
1453
  if (options.legend !== false) {
1282
1454
  renderTopLegend(
1283
1455
  svg,
@@ -1329,7 +1501,14 @@ function renderScatterChart(instance) {
1329
1501
  });
1330
1502
  drawAnnotations(svg, options, plot, xInfo.scale, yScale, theme);
1331
1503
  const labels = dataLabelConfig(options);
1332
- const markGroup = append(svg, svgEl("g", { class: "vd-chart-marks vd-chart-scatter" }));
1504
+ const markGroup = append(
1505
+ svg,
1506
+ svgEl("g", {
1507
+ class: "vd-chart-marks vd-chart-scatter",
1508
+ role: "graphics-object",
1509
+ "aria-roledescription": "data points"
1510
+ })
1511
+ );
1333
1512
  rows.forEach((row) => {
1334
1513
  const cx = xInfo.scale(xInfo.mapValue(row.x));
1335
1514
  const cy = yScale(row.y);
@@ -1343,6 +1522,7 @@ function renderScatterChart(instance) {
1343
1522
  fill,
1344
1523
  opacity: options.pointOpacity ?? 0.88,
1345
1524
  role: "graphics-symbol",
1525
+ "aria-roledescription": "data point",
1346
1526
  "aria-label": `${formatCategory(row.x)}: ${formatNumber(row.y)}`
1347
1527
  });
1348
1528
  makeInteractive(circle, typeof options.onPointClick === "function", options.tooltip !== false);
@@ -1363,6 +1543,12 @@ function renderScatterChart(instance) {
1363
1543
  if (labels) drawDataLabel(svg, cx, cy - 8, row.y, labels, theme);
1364
1544
  append(markGroup, circle);
1365
1545
  });
1546
+ setupKeyboardNavigation(svg, markGroup);
1547
+ renderDataTable(instance, {
1548
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1549
+ headers: [options.xAxis?.label || "X", options.yAxis?.label || "Y"],
1550
+ rows: rows.map((r) => [formatCategory(r.x), formatNumber(r.y)])
1551
+ });
1366
1552
  if (options.legend && color && color.scale) {
1367
1553
  renderTopLegend(
1368
1554
  svg,
@@ -1431,7 +1617,14 @@ function renderDonutChart(instance) {
1431
1617
  const innerRadius = outerRadius * ratio;
1432
1618
  const colorScale = scaleOrdinal({ domain: rows.map((row) => row.label), range: theme.colors });
1433
1619
  const labels = dataLabelConfig(options);
1434
- const markGroup = append(svg, svgEl("g", { class: "vd-chart-marks vd-chart-slices" }));
1620
+ const markGroup = append(
1621
+ svg,
1622
+ svgEl("g", {
1623
+ class: "vd-chart-marks vd-chart-slices",
1624
+ role: "graphics-object",
1625
+ "aria-roledescription": "pie slices"
1626
+ })
1627
+ );
1435
1628
  let cursor = -Math.PI / 2;
1436
1629
  rows.forEach((row) => {
1437
1630
  const angle = row.value / total * TAU;
@@ -1445,6 +1638,7 @@ function renderDonutChart(instance) {
1445
1638
  stroke: theme.backgroundColor,
1446
1639
  "stroke-width": 2,
1447
1640
  role: "graphics-symbol",
1641
+ "aria-roledescription": "slice",
1448
1642
  "aria-label": `${formatCategory(row.label)}: ${formatNumber(row.value)}`
1449
1643
  });
1450
1644
  makeInteractive(path, typeof options.onSliceClick === "function", options.tooltip !== false);
@@ -1469,6 +1663,16 @@ function renderDonutChart(instance) {
1469
1663
  }
1470
1664
  append(markGroup, path);
1471
1665
  });
1666
+ setupKeyboardNavigation(svg, markGroup);
1667
+ renderDataTable(instance, {
1668
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1669
+ headers: ["Category", "Value", "Percentage"],
1670
+ rows: rows.map((r) => [
1671
+ formatCategory(r.label),
1672
+ formatNumber(r.value),
1673
+ total > 0 ? `${Math.round(r.value / total * 100)}%` : "0%"
1674
+ ])
1675
+ });
1472
1676
  if (innerRadius > 16 && options.centerLabel !== false) {
1473
1677
  append(
1474
1678
  svg,
@@ -1519,7 +1723,11 @@ var ChartInstance = class {
1519
1723
  }
1520
1724
  render() {
1521
1725
  if (this.destroyed) return this;
1726
+ const tableHeight = dataTableContribution(this.target);
1522
1727
  this.renderer(this);
1728
+ if (!Number(this.options.height) && dataTableContribution(this.target) !== tableHeight) {
1729
+ this.renderer(this);
1730
+ }
1523
1731
  return this;
1524
1732
  }
1525
1733
  update(nextOptions = {}) {
@@ -1533,11 +1741,12 @@ var ChartInstance = class {
1533
1741
  setupResizeObserver() {
1534
1742
  if (this.options.responsive === false || !hasWindow() || typeof ResizeObserver === "undefined")
1535
1743
  return;
1744
+ const chartHeight = () => Math.max(0, this.target.clientHeight - dataTableContribution(this.target));
1536
1745
  let lastWidth = this.target.clientWidth;
1537
- let lastHeight = this.target.clientHeight;
1746
+ let lastHeight = chartHeight();
1538
1747
  this.resizeObserver = new ResizeObserver(() => {
1539
1748
  const width = this.target.clientWidth;
1540
- const height = this.target.clientHeight;
1749
+ const height = chartHeight();
1541
1750
  if (width === lastWidth && height === lastHeight) return;
1542
1751
  lastWidth = width;
1543
1752
  lastHeight = height;
@@ -1651,7 +1860,13 @@ var CHART_PROPS = {
1651
1860
  xFormat: { type: Function, default: void 0 },
1652
1861
  yFormat: { type: Function, default: void 0 },
1653
1862
  xAxis: { type: Object, default: void 0 },
1654
- yAxis: { type: Object, default: void 0 }
1863
+ yAxis: { type: Object, default: void 0 },
1864
+ // Accessible data table: `true` (sr-only, default) / `false` / `'visible'`.
1865
+ dataTable: { type: [Boolean, String], default: void 0 },
1866
+ // Override the SVG `aria-roledescription` (defaults to `${kind} chart`).
1867
+ ariaRoleDescription: { type: String, default: void 0 },
1868
+ // Avoid colliding with the standard root `role` fallthrough attribute.
1869
+ svgRole: { type: String, default: void 0 }
1655
1870
  };
1656
1871
  function optionsFrom(target, props, emit) {
1657
1872
  return {
@@ -1685,6 +1900,9 @@ function optionsFrom(target, props, emit) {
1685
1900
  yFormat: props.yFormat,
1686
1901
  xAxis: props.xAxis,
1687
1902
  yAxis: props.yAxis,
1903
+ dataTable: props.dataTable,
1904
+ ariaRoleDescription: props.ariaRoleDescription,
1905
+ role: props.svgRole,
1688
1906
  // Forward the core's mark-click callbacks as the Vue emit family. Passing
1689
1907
  // all three unconditionally is safe: the core only fires the callback
1690
1908
  // relevant to the rendered chart type (bars → onBarClick, points →
@@ -1743,7 +1961,10 @@ var VdChart = defineComponent({
1743
1961
  props.xFormat,
1744
1962
  props.yFormat,
1745
1963
  props.xAxis,
1746
- props.yAxis
1964
+ props.yAxis,
1965
+ props.dataTable,
1966
+ props.ariaRoleDescription,
1967
+ props.svgRole
1747
1968
  ],
1748
1969
  () => {
1749
1970
  if (!instance) return;