@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.
@@ -180,9 +180,22 @@ function normalizeMargin(value, fallback = DEFAULT_MARGIN) {
180
180
  left: Number(value?.left ?? fallback.left)
181
181
  };
182
182
  }
183
+ function dataTableContribution(target) {
184
+ if (!isElement(target) || typeof target.querySelectorAll !== "function") return 0;
185
+ let total = 0;
186
+ target.querySelectorAll(".vd-chart-data-table:not(.vd-chart-sr-only)").forEach((table) => {
187
+ const style = typeof getComputedStyle === "function" ? getComputedStyle(table) : null;
188
+ const marginTop = style ? parseFloat(style.marginTop) || 0 : 0;
189
+ const marginBottom = style ? parseFloat(style.marginBottom) || 0 : 0;
190
+ total += (table.offsetHeight || 0) + marginTop + marginBottom;
191
+ });
192
+ return total;
193
+ }
183
194
  function measureTarget(target, options) {
184
195
  const width = Number(options.width) || Math.round(target.clientWidth) || DEFAULT_WIDTH;
185
- const height = Number(options.height) || Math.round(target.clientHeight) || DEFAULT_HEIGHT;
196
+ const targetHeight = Math.round(Number(target.clientHeight));
197
+ const availableHeight = Math.max(0, targetHeight - dataTableContribution(target));
198
+ const height = Number(options.height) || (targetHeight > 0 ? availableHeight : DEFAULT_HEIGHT);
186
199
  return {
187
200
  width: Math.max(160, width),
188
201
  height: Math.max(140, height)
@@ -495,6 +508,74 @@ function makeInteractive(mark, hasClick, hasTooltip) {
495
508
  mark.setAttribute("focusable", "true");
496
509
  }
497
510
  }
511
+ function setupKeyboardNavigation(svg, markGroup) {
512
+ svg.addEventListener("keydown", (event) => {
513
+ const active = document.activeElement;
514
+ if (!active || !markGroup.contains(active)) return;
515
+ const marks = Array.from(markGroup.querySelectorAll('[tabindex="0"]'));
516
+ if (!marks.length) return;
517
+ const currentIndex = marks.indexOf(active);
518
+ if (currentIndex === -1) return;
519
+ let targetIndex = -1;
520
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
521
+ event.preventDefault();
522
+ targetIndex = (currentIndex + 1) % marks.length;
523
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
524
+ event.preventDefault();
525
+ targetIndex = (currentIndex - 1 + marks.length) % marks.length;
526
+ } else if (event.key === "Home") {
527
+ event.preventDefault();
528
+ targetIndex = 0;
529
+ } else if (event.key === "End") {
530
+ event.preventDefault();
531
+ targetIndex = marks.length - 1;
532
+ } else if (event.key === "Escape") {
533
+ event.preventDefault();
534
+ active.blur();
535
+ }
536
+ if (targetIndex !== -1 && marks[targetIndex]) {
537
+ marks[targetIndex].focus();
538
+ }
539
+ });
540
+ }
541
+ function renderDataTable(instance, tableData) {
542
+ const { target, options } = instance;
543
+ if (options.dataTable === false) return;
544
+ if (!tableData || !tableData.rows || !tableData.rows.length) return;
545
+ const table = document.createElement("table");
546
+ table.className = "vd-chart-data-table";
547
+ if (options.dataTable !== "visible") {
548
+ table.classList.add("vd-chart-sr-only");
549
+ }
550
+ const caption = document.createElement("caption");
551
+ caption.textContent = tableData.caption || options.title || options.ariaLabel || `${instance.kind} chart data`;
552
+ table.appendChild(caption);
553
+ if (tableData.headers && tableData.headers.length) {
554
+ const thead = document.createElement("thead");
555
+ const headerRow = document.createElement("tr");
556
+ tableData.headers.forEach((h2) => {
557
+ const th = document.createElement("th");
558
+ th.setAttribute("scope", "col");
559
+ th.textContent = String(h2);
560
+ headerRow.appendChild(th);
561
+ });
562
+ thead.appendChild(headerRow);
563
+ table.appendChild(thead);
564
+ }
565
+ const tbody = document.createElement("tbody");
566
+ tableData.rows.forEach((row) => {
567
+ const tr = document.createElement("tr");
568
+ row.forEach((cell, idx) => {
569
+ const el = idx === 0 ? document.createElement("th") : document.createElement("td");
570
+ if (idx === 0) el.setAttribute("scope", "row");
571
+ el.textContent = cell == null ? "" : String(cell);
572
+ tr.appendChild(el);
573
+ });
574
+ tbody.appendChild(tr);
575
+ });
576
+ table.appendChild(tbody);
577
+ target.appendChild(table);
578
+ }
498
579
  function createSvgShell(instance) {
499
580
  const { target, options } = instance;
500
581
  const size = measureTarget(target, options);
@@ -511,7 +592,8 @@ function createSvgShell(instance) {
511
592
  width: size.width,
512
593
  height: size.height,
513
594
  viewBox: `0 0 ${size.width} ${size.height}`,
514
- role: "img"
595
+ role: options.role || "graphics-document document",
596
+ "aria-roledescription": options.ariaRoleDescription || `${instance.kind} chart`
515
597
  });
516
598
  const titleId = nextId("vd-chart-title");
517
599
  const descId = nextId("vd-chart-desc");
@@ -582,7 +664,7 @@ function drawAxisLine(svg, x1, y1, x2, y2, theme) {
582
664
  }
583
665
  function drawCartesianAxes(svg, config) {
584
666
  const { plot, xScale, yScale, xTicks, yTicks, theme, options, categoricalX } = config;
585
- const axisGroup = append(svg, svgEl("g", { class: "vd-chart-axes" }));
667
+ const axisGroup = append(svg, svgEl("g", { class: "vd-chart-axes", "aria-hidden": "true" }));
586
668
  yTicks.forEach((tick) => {
587
669
  const y = yScale(tick);
588
670
  if (!isFiniteNumber(y)) return;
@@ -825,7 +907,14 @@ function drawDataLabel(svg, x, y, value, cfg, theme, anchor = "middle") {
825
907
  function drawAnnotations(svg, options, plot, xScale, yScale, theme) {
826
908
  const annotations = toArray(options.annotations);
827
909
  if (!annotations.length) return;
828
- const group = append(svg, svgEl("g", { class: "vd-chart-annotations" }));
910
+ const group = append(
911
+ svg,
912
+ svgEl("g", {
913
+ class: "vd-chart-annotations",
914
+ role: "graphics-object",
915
+ "aria-roledescription": "annotations"
916
+ })
917
+ );
829
918
  annotations.forEach((ann) => {
830
919
  const color = ann.color || theme.mutedTextColor;
831
920
  const dash = ann.dash === false ? null : "4 3";
@@ -945,7 +1034,14 @@ function renderBarChart(instance) {
945
1034
  });
946
1035
  drawAnnotations(svg, options, plot, xScale, yScale, theme);
947
1036
  const labels = dataLabelConfig(options);
948
- const markGroup = append(svg, svgEl("g", { class: "vd-chart-marks vd-chart-bars" }));
1037
+ const markGroup = append(
1038
+ svg,
1039
+ svgEl("g", {
1040
+ class: "vd-chart-marks vd-chart-bars",
1041
+ role: "graphics-object",
1042
+ "aria-roledescription": "data points"
1043
+ })
1044
+ );
949
1045
  const baseline = yScale(Math.min(Math.max(0, yDomain[0]), yDomain[1]));
950
1046
  rows.forEach((row) => {
951
1047
  const x = xScale(row.x);
@@ -963,6 +1059,7 @@ function renderBarChart(instance) {
963
1059
  rx: 3,
964
1060
  fill,
965
1061
  role: "graphics-symbol",
1062
+ "aria-roledescription": "bar",
966
1063
  "aria-label": `${formatCategory(row.x)}: ${formatNumber(row.y)}`
967
1064
  });
968
1065
  makeInteractive(rect, typeof options.onBarClick === "function", options.tooltip !== false);
@@ -984,6 +1081,12 @@ function renderBarChart(instance) {
984
1081
  if (labels) drawDataLabel(svg, x + xScale.bandwidth() / 2, rectY - 4, row.y, labels, theme);
985
1082
  append(markGroup, rect);
986
1083
  });
1084
+ setupKeyboardNavigation(svg, markGroup);
1085
+ renderDataTable(instance, {
1086
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1087
+ headers: [options.xAxis?.label || "Category", options.yAxis?.label || "Value"],
1088
+ rows: rows.map((r) => [formatCategory(r.x), formatNumber(r.y)])
1089
+ });
987
1090
  if (options.legend && color && color.scale) {
988
1091
  renderTopLegend(
989
1092
  svg,
@@ -1037,7 +1140,14 @@ function renderMultiBarChart(instance) {
1037
1140
  });
1038
1141
  drawAnnotations(svg, options, plot, xScale, yScale, theme);
1039
1142
  const labels = dataLabelConfig(options);
1040
- const markGroup = append(svg, svgEl("g", { class: "vd-chart-marks vd-chart-bars" }));
1143
+ const markGroup = append(
1144
+ svg,
1145
+ svgEl("g", {
1146
+ class: "vd-chart-marks vd-chart-bars",
1147
+ role: "graphics-object",
1148
+ "aria-roledescription": "grouped bars"
1149
+ })
1150
+ );
1041
1151
  const baseline = yScale(Math.min(Math.max(0, yDomain[0]), yDomain[1]));
1042
1152
  seriesList.forEach((series) => {
1043
1153
  const fill = seriesColor(series, theme);
@@ -1057,6 +1167,7 @@ function renderMultiBarChart(instance) {
1057
1167
  rx: 3,
1058
1168
  fill,
1059
1169
  role: "graphics-symbol",
1170
+ "aria-roledescription": "bar",
1060
1171
  "aria-label": `${series.name} \u2014 ${formatCategory(row.x)}: ${formatNumber(row.y)}`
1061
1172
  });
1062
1173
  makeInteractive(rect, typeof options.onBarClick === "function", options.tooltip !== false);
@@ -1089,6 +1200,20 @@ function renderMultiBarChart(instance) {
1089
1200
  append(markGroup, rect);
1090
1201
  });
1091
1202
  });
1203
+ setupKeyboardNavigation(svg, markGroup);
1204
+ const tableRows = categories.map((cat) => {
1205
+ const rowCells = [cat];
1206
+ seriesList.forEach((s) => {
1207
+ const match = s.rows.find((r) => String(r.x) === cat);
1208
+ rowCells.push(match && isFiniteNumber(match.y) ? formatNumber(match.y) : "");
1209
+ });
1210
+ return rowCells;
1211
+ });
1212
+ renderDataTable(instance, {
1213
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1214
+ headers: [options.xAxis?.label || "Category", ...seriesList.map((s) => s.name)],
1215
+ rows: tableRows
1216
+ });
1092
1217
  if (options.legend !== false) {
1093
1218
  renderTopLegend(
1094
1219
  svg,
@@ -1149,7 +1274,14 @@ function renderLineLikeChart(instance, mode) {
1149
1274
  });
1150
1275
  drawAnnotations(svg, options, plot, xInfo.scale, yScale, theme);
1151
1276
  const labels = dataLabelConfig(options);
1152
- const markGroup = append(svg, svgEl("g", { class: `vd-chart-marks vd-chart-${mode}` }));
1277
+ const markGroup = append(
1278
+ svg,
1279
+ svgEl("g", {
1280
+ class: `vd-chart-marks vd-chart-${mode}`,
1281
+ role: "graphics-object",
1282
+ "aria-roledescription": `${mode} series`
1283
+ })
1284
+ );
1153
1285
  if (mode === "area") {
1154
1286
  const baseline = yScale(Math.max(0, yDomain[0]));
1155
1287
  append(
@@ -1186,6 +1318,7 @@ function renderLineLikeChart(instance, mode) {
1186
1318
  stroke: color,
1187
1319
  "stroke-width": 2,
1188
1320
  role: "graphics-symbol",
1321
+ "aria-roledescription": "data point",
1189
1322
  "aria-label": `${formatCategory(point.xValue)}: ${formatNumber(point.yValue)}`
1190
1323
  });
1191
1324
  makeInteractive(
@@ -1215,6 +1348,12 @@ function renderLineLikeChart(instance, mode) {
1215
1348
  (point) => drawDataLabel(svg, point.x, point.y - 8, point.yValue, labels, theme)
1216
1349
  );
1217
1350
  }
1351
+ setupKeyboardNavigation(svg, markGroup);
1352
+ renderDataTable(instance, {
1353
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1354
+ headers: [options.xAxis?.label || "X", options.yAxis?.label || "Y"],
1355
+ rows: points.map((p) => [formatCategory(p.xValue), formatNumber(p.yValue)])
1356
+ });
1218
1357
  }
1219
1358
  function renderMultiLineChart(instance, mode) {
1220
1359
  const shell = createSvgShell(instance);
@@ -1250,7 +1389,14 @@ function renderMultiLineChart(instance, mode) {
1250
1389
  });
1251
1390
  drawAnnotations(svg, options, plot, xInfo.scale, yScale, theme);
1252
1391
  const labels = dataLabelConfig(options);
1253
- const markGroup = append(svg, svgEl("g", { class: `vd-chart-marks vd-chart-${mode}` }));
1392
+ const markGroup = append(
1393
+ svg,
1394
+ svgEl("g", {
1395
+ class: `vd-chart-marks vd-chart-${mode}`,
1396
+ role: "graphics-object",
1397
+ "aria-roledescription": `multi-series ${mode}`
1398
+ })
1399
+ );
1254
1400
  const baseline = yScale(Math.max(0, yDomain[0]));
1255
1401
  seriesList.forEach((series) => {
1256
1402
  const stroke = seriesColor(series, theme);
@@ -1297,6 +1443,7 @@ function renderMultiLineChart(instance, mode) {
1297
1443
  stroke,
1298
1444
  "stroke-width": 2,
1299
1445
  role: "graphics-symbol",
1446
+ "aria-roledescription": "data point",
1300
1447
  "aria-label": `${series.name} ${formatCategory(point.xValue)}: ${formatNumber(point.yValue)}`
1301
1448
  });
1302
1449
  makeInteractive(
@@ -1329,6 +1476,31 @@ function renderMultiLineChart(instance, mode) {
1329
1476
  );
1330
1477
  }
1331
1478
  });
1479
+ setupKeyboardNavigation(svg, markGroup);
1480
+ const xKey = (value) => String(xInfo.mapValue(value));
1481
+ const uniqueX = /* @__PURE__ */ new Map();
1482
+ for (const row of allRows) {
1483
+ const key = xKey(row.x);
1484
+ if (!uniqueX.has(key)) uniqueX.set(key, row.x);
1485
+ }
1486
+ const seriesValues = seriesList.map((s) => {
1487
+ const byX = /* @__PURE__ */ new Map();
1488
+ for (const row of s.rows) byX.set(xKey(row.x), row.y);
1489
+ return byX;
1490
+ });
1491
+ const tableRows = [...uniqueX.entries()].map(([key, xVal]) => {
1492
+ const rowCells = [formatCategory(xVal)];
1493
+ seriesValues.forEach((byX) => {
1494
+ const y = byX.get(key);
1495
+ rowCells.push(isFiniteNumber(y) ? formatNumber(y) : "");
1496
+ });
1497
+ return rowCells;
1498
+ });
1499
+ renderDataTable(instance, {
1500
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1501
+ headers: [options.xAxis?.label || "X", ...seriesList.map((s) => s.name)],
1502
+ rows: tableRows
1503
+ });
1332
1504
  if (options.legend !== false) {
1333
1505
  renderTopLegend(
1334
1506
  svg,
@@ -1380,7 +1552,14 @@ function renderScatterChart(instance) {
1380
1552
  });
1381
1553
  drawAnnotations(svg, options, plot, xInfo.scale, yScale, theme);
1382
1554
  const labels = dataLabelConfig(options);
1383
- const markGroup = append(svg, svgEl("g", { class: "vd-chart-marks vd-chart-scatter" }));
1555
+ const markGroup = append(
1556
+ svg,
1557
+ svgEl("g", {
1558
+ class: "vd-chart-marks vd-chart-scatter",
1559
+ role: "graphics-object",
1560
+ "aria-roledescription": "data points"
1561
+ })
1562
+ );
1384
1563
  rows.forEach((row) => {
1385
1564
  const cx = xInfo.scale(xInfo.mapValue(row.x));
1386
1565
  const cy = yScale(row.y);
@@ -1394,6 +1573,7 @@ function renderScatterChart(instance) {
1394
1573
  fill,
1395
1574
  opacity: options.pointOpacity ?? 0.88,
1396
1575
  role: "graphics-symbol",
1576
+ "aria-roledescription": "data point",
1397
1577
  "aria-label": `${formatCategory(row.x)}: ${formatNumber(row.y)}`
1398
1578
  });
1399
1579
  makeInteractive(circle, typeof options.onPointClick === "function", options.tooltip !== false);
@@ -1414,6 +1594,12 @@ function renderScatterChart(instance) {
1414
1594
  if (labels) drawDataLabel(svg, cx, cy - 8, row.y, labels, theme);
1415
1595
  append(markGroup, circle);
1416
1596
  });
1597
+ setupKeyboardNavigation(svg, markGroup);
1598
+ renderDataTable(instance, {
1599
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1600
+ headers: [options.xAxis?.label || "X", options.yAxis?.label || "Y"],
1601
+ rows: rows.map((r) => [formatCategory(r.x), formatNumber(r.y)])
1602
+ });
1417
1603
  if (options.legend && color && color.scale) {
1418
1604
  renderTopLegend(
1419
1605
  svg,
@@ -1482,7 +1668,14 @@ function renderDonutChart(instance) {
1482
1668
  const innerRadius = outerRadius * ratio;
1483
1669
  const colorScale = scaleOrdinal({ domain: rows.map((row) => row.label), range: theme.colors });
1484
1670
  const labels = dataLabelConfig(options);
1485
- const markGroup = append(svg, svgEl("g", { class: "vd-chart-marks vd-chart-slices" }));
1671
+ const markGroup = append(
1672
+ svg,
1673
+ svgEl("g", {
1674
+ class: "vd-chart-marks vd-chart-slices",
1675
+ role: "graphics-object",
1676
+ "aria-roledescription": "pie slices"
1677
+ })
1678
+ );
1486
1679
  let cursor = -Math.PI / 2;
1487
1680
  rows.forEach((row) => {
1488
1681
  const angle = row.value / total * TAU;
@@ -1496,6 +1689,7 @@ function renderDonutChart(instance) {
1496
1689
  stroke: theme.backgroundColor,
1497
1690
  "stroke-width": 2,
1498
1691
  role: "graphics-symbol",
1692
+ "aria-roledescription": "slice",
1499
1693
  "aria-label": `${formatCategory(row.label)}: ${formatNumber(row.value)}`
1500
1694
  });
1501
1695
  makeInteractive(path, typeof options.onSliceClick === "function", options.tooltip !== false);
@@ -1520,6 +1714,16 @@ function renderDonutChart(instance) {
1520
1714
  }
1521
1715
  append(markGroup, path);
1522
1716
  });
1717
+ setupKeyboardNavigation(svg, markGroup);
1718
+ renderDataTable(instance, {
1719
+ caption: options.title || options.ariaLabel || `${instance.kind} chart data`,
1720
+ headers: ["Category", "Value", "Percentage"],
1721
+ rows: rows.map((r) => [
1722
+ formatCategory(r.label),
1723
+ formatNumber(r.value),
1724
+ total > 0 ? `${Math.round(r.value / total * 100)}%` : "0%"
1725
+ ])
1726
+ });
1523
1727
  if (innerRadius > 16 && options.centerLabel !== false) {
1524
1728
  append(
1525
1729
  svg,
@@ -1570,7 +1774,11 @@ var ChartInstance = class {
1570
1774
  }
1571
1775
  render() {
1572
1776
  if (this.destroyed) return this;
1777
+ const tableHeight = dataTableContribution(this.target);
1573
1778
  this.renderer(this);
1779
+ if (!Number(this.options.height) && dataTableContribution(this.target) !== tableHeight) {
1780
+ this.renderer(this);
1781
+ }
1574
1782
  return this;
1575
1783
  }
1576
1784
  update(nextOptions = {}) {
@@ -1584,11 +1792,12 @@ var ChartInstance = class {
1584
1792
  setupResizeObserver() {
1585
1793
  if (this.options.responsive === false || !hasWindow() || typeof ResizeObserver === "undefined")
1586
1794
  return;
1795
+ const chartHeight = () => Math.max(0, this.target.clientHeight - dataTableContribution(this.target));
1587
1796
  let lastWidth = this.target.clientWidth;
1588
- let lastHeight = this.target.clientHeight;
1797
+ let lastHeight = chartHeight();
1589
1798
  this.resizeObserver = new ResizeObserver(() => {
1590
1799
  const width = this.target.clientWidth;
1591
- const height = this.target.clientHeight;
1800
+ const height = chartHeight();
1592
1801
  if (width === lastWidth && height === lastHeight) return;
1593
1802
  lastWidth = width;
1594
1803
  lastHeight = height;
@@ -1702,7 +1911,13 @@ var CHART_PROPS = {
1702
1911
  xFormat: { type: Function, default: void 0 },
1703
1912
  yFormat: { type: Function, default: void 0 },
1704
1913
  xAxis: { type: Object, default: void 0 },
1705
- yAxis: { type: Object, default: void 0 }
1914
+ yAxis: { type: Object, default: void 0 },
1915
+ // Accessible data table: `true` (sr-only, default) / `false` / `'visible'`.
1916
+ dataTable: { type: [Boolean, String], default: void 0 },
1917
+ // Override the SVG `aria-roledescription` (defaults to `${kind} chart`).
1918
+ ariaRoleDescription: { type: String, default: void 0 },
1919
+ // Avoid colliding with the standard root `role` fallthrough attribute.
1920
+ svgRole: { type: String, default: void 0 }
1706
1921
  };
1707
1922
  function optionsFrom(target, props, emit) {
1708
1923
  return {
@@ -1736,6 +1951,9 @@ function optionsFrom(target, props, emit) {
1736
1951
  yFormat: props.yFormat,
1737
1952
  xAxis: props.xAxis,
1738
1953
  yAxis: props.yAxis,
1954
+ dataTable: props.dataTable,
1955
+ ariaRoleDescription: props.ariaRoleDescription,
1956
+ role: props.svgRole,
1739
1957
  // Forward the core's mark-click callbacks as the Vue emit family. Passing
1740
1958
  // all three unconditionally is safe: the core only fires the callback
1741
1959
  // relevant to the rendered chart type (bars → onBarClick, points →
@@ -1794,7 +2012,10 @@ var VdChart = (0, import_vue.defineComponent)({
1794
2012
  props.xFormat,
1795
2013
  props.yFormat,
1796
2014
  props.xAxis,
1797
- props.yAxis
2015
+ props.yAxis,
2016
+ props.dataTable,
2017
+ props.ariaRoleDescription,
2018
+ props.svgRole
1798
2019
  ],
1799
2020
  () => {
1800
2021
  if (!instance) return;