@cfasim-ui/docs 0.4.16 → 0.5.0

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.
@@ -21,6 +21,7 @@ import {
21
21
  TICK_LABEL_FONT_SIZE,
22
22
  TICK_LABEL_OPACITY,
23
23
  LEGEND_FONT_SIZE,
24
+ pickContrastColor,
24
25
  resolveLabelStyle,
25
26
  parseDate,
26
27
  isAllDates,
@@ -34,6 +35,8 @@ import {
34
35
  type ChartTooltipValue,
35
36
  type BlendMode,
36
37
  type LineMarkStyle,
38
+ type LabelStyle,
39
+ type ChartPadding,
37
40
  } from "../_shared/index.js";
38
41
 
39
42
  export type BarChartData = ChartData;
@@ -96,6 +99,66 @@ export interface BarSummaryLine extends LineMarkStyle {
96
99
  valueMax?: number;
97
100
  }
98
101
 
102
+ /** Context passed to a `barLabels.format` function. */
103
+ export interface BarLabelContext {
104
+ value: number;
105
+ categoryIndex: number;
106
+ seriesIndex: number;
107
+ /** The resolved category label string. */
108
+ category: string;
109
+ }
110
+
111
+ /**
112
+ * Styling and behavior for value labels drawn directly on the bars (an
113
+ * alternative to reading values off the value axis — see `valueAxis`).
114
+ * Most useful with `layout="stacked"`, where each segment carries its own
115
+ * value. Extends `LabelStyle` (`fontSize` / `fontWeight`).
116
+ */
117
+ export interface BarLabelStyle extends LabelStyle {
118
+ /**
119
+ * How to turn a bar's numeric value into label text. A `NumberFormat`
120
+ * (preset/printf/function), or a function receiving `(value, context)`
121
+ * for fully custom strings (e.g. `">99%"`). Return `""` to omit a
122
+ * label. When unset, falls back to `valueTickFormat`, then a default.
123
+ */
124
+ format?: NumberFormat | ((value: number, ctx: BarLabelContext) => string);
125
+ /**
126
+ * Label text color. `"auto"` (default) picks white or near-black per
127
+ * segment by the fill's luminance for readable contrast; outside-placed
128
+ * labels use the chart's text color. Any other CSS color is used as-is
129
+ * for every label.
130
+ */
131
+ color?: string;
132
+ /**
133
+ * Where to draw each label. `"auto"` (default) centers it inside the
134
+ * segment when the text fits, otherwise just past the segment's
135
+ * high-value edge. `"inside"` / `"outside"` force one placement.
136
+ */
137
+ placement?: "auto" | "inside" | "outside";
138
+ /**
139
+ * Alignment of an *inside* label along the value axis. `"center"`
140
+ * (default) centers it in its segment; `"start"` pins it to the
141
+ * segment's low-value edge (left edge for horizontal bars); `"end"`
142
+ * pins it to the high-value edge. Outside/overflow labels ignore this
143
+ * and always sit just past the high-value edge.
144
+ */
145
+ align?: "start" | "center" | "end";
146
+ /**
147
+ * How to handle labels that would collide along the value axis (common
148
+ * with many small adjacent segments). `"shift"` (default) nudges them
149
+ * apart toward higher values; `"hide"` drops the colliding label. Either
150
+ * way, a label pushed past the chart edge is hidden.
151
+ */
152
+ overlap?: "shift" | "hide";
153
+ /**
154
+ * Segments smaller than this many pixels (along the value axis) never
155
+ * get an *inside* label — they fall back to outside placement (or hide,
156
+ * per `overlap`). Use to suppress labels crammed into thin slivers.
157
+ * Default 0 (size alone never forces a label out; text fit still does).
158
+ */
159
+ minSize?: number;
160
+ }
161
+
99
162
  interface BarChartProps extends ChartCommonProps {
100
163
  /** Single-series values. Equivalent to `y`. */
101
164
  data?: BarChartData;
@@ -151,6 +214,15 @@ interface BarChartProps extends ChartCommonProps {
151
214
  valueTickFormat?: NumberFormat;
152
215
  /** Formatter for category-axis labels. Receives the resolved category string. */
153
216
  categoryFormat?: (label: string, index: number) => string;
217
+ /**
218
+ * Alignment of the category labels in horizontal orientation. `"end"`
219
+ * (default) right-aligns them against the plot's left edge; `"start"`
220
+ * left-aligns them at the chart's left margin; `"center"` centers them
221
+ * in the margin. The `categoryHeader`, if set, follows the same
222
+ * alignment. Ignored in vertical orientation (labels stay centered
223
+ * under each column).
224
+ */
225
+ categoryAlign?: "start" | "center" | "end";
154
226
  /**
155
227
  * Date-tick formatter for date-mode category labels (auto-detected
156
228
  * when every entry of `categories` parses as a date). Ignored unless
@@ -172,6 +244,33 @@ interface BarChartProps extends ChartCommonProps {
172
244
  */
173
245
  groupGap?: number;
174
246
  valueGrid?: boolean;
247
+ /**
248
+ * Draw the value axis (its line and tick labels). Default true. Set
249
+ * false for a clean "value-on-bar" look where each segment is labeled
250
+ * via `barLabels` and there's no axis to read against.
251
+ */
252
+ valueAxis?: boolean;
253
+ /**
254
+ * Draw the numeric value directly on each bar instead of (or alongside)
255
+ * reading it off the value axis. `true` uses defaults; pass a
256
+ * `BarLabelStyle` to customize formatting, color, placement, and
257
+ * overlap handling. Most useful with `layout="stacked"`.
258
+ */
259
+ barLabels?: boolean | BarLabelStyle;
260
+ /**
261
+ * Header drawn above the category labels (e.g. "Scenario"). Aligned to
262
+ * match the category labels — right-edge-aligned in horizontal
263
+ * orientation, centered under each column in vertical. Reserves a row
264
+ * of space above the plot.
265
+ */
266
+ categoryHeader?: string;
267
+ /**
268
+ * Header drawn above the bars / value area (e.g. "Proportion of
269
+ * simulations"), left-aligned to where the bars start. Pairs with
270
+ * `categoryHeader` to label both columns of a value-on-bar chart.
271
+ * Reserves a row of space above the plot.
272
+ */
273
+ valueHeader?: string;
175
274
  }
176
275
 
177
276
  const props = withDefaults(defineProps<BarChartProps>(), {
@@ -184,6 +283,7 @@ const props = withDefaults(defineProps<BarChartProps>(), {
184
283
  tooltipClamp: "chart",
185
284
  valueGrid: true,
186
285
  valueScaleType: "linear",
286
+ valueAxis: true,
187
287
  });
188
288
 
189
289
  const emit = defineEmits<{
@@ -861,6 +961,27 @@ function pointerToIndex(clientX: number, clientY: number): number | null {
861
961
  return Math.max(0, Math.min(n - 1, Math.floor(local / slotSize.value)));
862
962
  }
863
963
 
964
+ /** Height reserved above the plot for the category/value column headers. */
965
+ const HEADER_ROW_HEIGHT = 22;
966
+
967
+ const hasHeaders = computed(
968
+ () => !!(props.categoryHeader || props.valueHeader),
969
+ );
970
+
971
+ /**
972
+ * `chartPadding` with extra top room reserved for the column headers when
973
+ * either is set, so they don't overlap the legend or the plot.
974
+ */
975
+ const effectiveChartPadding = computed<ChartPadding | undefined>(() => {
976
+ const extraTop = hasHeaders.value ? HEADER_ROW_HEIGHT : 0;
977
+ const base = props.chartPadding;
978
+ if (!extraTop) return base;
979
+ if (base == null) return { top: extraTop };
980
+ if (typeof base === "number")
981
+ return { top: base + extraTop, right: base, bottom: base, left: base };
982
+ return { ...base, top: (base.top ?? 0) + extraTop };
983
+ });
984
+
864
985
  const {
865
986
  containerRef,
866
987
  svgRef,
@@ -897,7 +1018,7 @@ const {
897
1018
  filename: () => props.filename,
898
1019
  downloadLink: () => props.downloadLink,
899
1020
  downloadButton: () => props.downloadButton,
900
- chartPadding: () => props.chartPadding,
1021
+ chartPadding: () => effectiveChartPadding.value,
901
1022
  inlineLegendLabels: () => inlineLegendLabels.value,
902
1023
  hasTooltipSlot: () => hasTooltipSlot.value,
903
1024
  getCsv: toCsv,
@@ -921,6 +1042,21 @@ const legendResolved = computed(() =>
921
1042
  resolveLabelStyle(props.legendStyle, { fontSize: LEGEND_FONT_SIZE }),
922
1043
  );
923
1044
 
1045
+ /** Small inset from the chart's left edge for start-aligned category labels. */
1046
+ const CATEGORY_LABEL_INSET = 2;
1047
+
1048
+ /**
1049
+ * Left x-anchor for the title and inline legend. When the category labels
1050
+ * are start-aligned (horizontal), the title/legend align to that same left
1051
+ * edge so the whole header column lines up; otherwise they sit at the
1052
+ * plot's left edge.
1053
+ */
1054
+ const headerLeftX = computed(() =>
1055
+ props.categoryAlign === "start" && !isVertical.value
1056
+ ? CATEGORY_LABEL_INSET
1057
+ : padding.value.left,
1058
+ );
1059
+
924
1060
  /** Resolved title style with defaults applied. */
925
1061
  const titleResolved = computed(() => {
926
1062
  const s = props.titleStyle;
@@ -928,7 +1064,7 @@ const titleResolved = computed(() => {
928
1064
  const b = bounds.value;
929
1065
  const x =
930
1066
  align === "left"
931
- ? b.left
1067
+ ? headerLeftX.value
932
1068
  : align === "right"
933
1069
  ? b.right
934
1070
  : b.left + b.width / 2;
@@ -1007,7 +1143,7 @@ const hoverBand = computed(() => {
1007
1143
  */
1008
1144
  const positionedLegendItems = computed(() => {
1009
1145
  const positions = inlineLegendLayout.value.positions;
1010
- const pad = padding.value.left;
1146
+ const pad = headerLeftX.value;
1011
1147
  const baseY = legendY.value;
1012
1148
  return inlineLegendItems.value.map((item, i) => {
1013
1149
  const pos = positions[i];
@@ -1018,6 +1154,306 @@ const positionedLegendItems = computed(() => {
1018
1154
  };
1019
1155
  });
1020
1156
  });
1157
+
1158
+ const BAR_LABEL_FONT_SIZE = 11;
1159
+ /** Estimated glyph width as a fraction of font size (matches axis logic). */
1160
+ const BAR_LABEL_CHAR_WIDTH = 0.6;
1161
+ /** Inset of an inside label / offset of an outside label from the edge. */
1162
+ const BAR_LABEL_PAD = 4;
1163
+ /** Minimum gap between two labels when decluttering. */
1164
+ const BAR_LABEL_GAP = 4;
1165
+
1166
+ interface ResolvedBarLabels {
1167
+ format?: NumberFormat | ((value: number, ctx: BarLabelContext) => string);
1168
+ color: string;
1169
+ placement: "auto" | "inside" | "outside";
1170
+ align: "start" | "center" | "end";
1171
+ overlap: "shift" | "hide";
1172
+ minSize: number;
1173
+ fontSize: number;
1174
+ fontWeight: number | string | undefined;
1175
+ }
1176
+
1177
+ const barLabelOptions = computed<ResolvedBarLabels | null>(() => {
1178
+ const b = props.barLabels;
1179
+ if (!b) return null;
1180
+ const o = b === true ? {} : b;
1181
+ return {
1182
+ format: o.format,
1183
+ color: o.color ?? "auto",
1184
+ placement: o.placement ?? "auto",
1185
+ align: o.align ?? "center",
1186
+ overlap: o.overlap ?? "shift",
1187
+ minSize: o.minSize ?? 0,
1188
+ fontSize: o.fontSize ?? BAR_LABEL_FONT_SIZE,
1189
+ fontWeight: o.fontWeight,
1190
+ };
1191
+ });
1192
+
1193
+ function formatBarLabel(value: number, ctx: BarLabelContext): string {
1194
+ const f = barLabelOptions.value?.format;
1195
+ if (typeof f === "function") return f(value, ctx);
1196
+ if (f !== undefined) return formatNumber(value, f);
1197
+ if (props.valueTickFormat !== undefined)
1198
+ return formatNumber(value, props.valueTickFormat);
1199
+ return formatTick(value);
1200
+ }
1201
+
1202
+ /**
1203
+ * Fill color of the bar covering pixel (px, py) within category `cat`, or
1204
+ * null when no bar sits there (the chart background). Used to choose a
1205
+ * readable color for a label that overflows onto a neighboring segment.
1206
+ */
1207
+ function barColorAt(cat: number, px: number, py: number): string | null {
1208
+ // Iterate in reverse so the topmost (last-drawn) bar wins under overlap.
1209
+ for (let i = bars.value.length - 1; i >= 0; i--) {
1210
+ const b = bars.value[i];
1211
+ if (b.categoryIndex !== cat) continue;
1212
+ if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h)
1213
+ return b.color;
1214
+ }
1215
+ return null;
1216
+ }
1217
+
1218
+ interface BarLabelItem {
1219
+ key: string;
1220
+ x: number;
1221
+ y: number;
1222
+ text: string;
1223
+ anchor: "start" | "middle" | "end";
1224
+ fill: string;
1225
+ fontSize: number;
1226
+ fontWeight: number | string | undefined;
1227
+ }
1228
+
1229
+ const barLabelItems = computed<BarLabelItem[]>(() => {
1230
+ const opts = barLabelOptions.value;
1231
+ if (!opts) return [];
1232
+ const vertical = isVertical.value;
1233
+ const fontSize = opts.fontSize;
1234
+
1235
+ // Build a draft label per bar, computing its placement (inside vs
1236
+ // outside) and a 1D extent [lo, hi] along the value axis in a
1237
+ // "value-increasing" coordinate `u` (rightward when horizontal, upward
1238
+ // when vertical). Collision resolution then runs per category in `u`.
1239
+ interface Draft {
1240
+ bar: (typeof bars.value)[number];
1241
+ text: string;
1242
+ textLen: number;
1243
+ inside: boolean;
1244
+ cross: number; // fixed coordinate on the categorical axis
1245
+ lo: number; // low-value edge of the text in u
1246
+ hi: number; // high-value edge of the text in u
1247
+ // Horizontal text-anchor for this label (vertical bars always center).
1248
+ svgAnchor: "start" | "middle" | "end";
1249
+ hidden: boolean;
1250
+ }
1251
+
1252
+ const drafts: Draft[] = [];
1253
+ for (const bar of bars.value) {
1254
+ const text = formatBarLabel(bar.value, {
1255
+ value: bar.value,
1256
+ categoryIndex: bar.categoryIndex,
1257
+ seriesIndex: bar.seriesIndex,
1258
+ category:
1259
+ categoryLabels.value[bar.categoryIndex] ?? String(bar.categoryIndex),
1260
+ });
1261
+ if (!text) continue;
1262
+
1263
+ const segSize = vertical ? bar.h : bar.w;
1264
+ const textLen = text.length * fontSize * BAR_LABEL_CHAR_WIDTH;
1265
+ const fits =
1266
+ segSize >= textLen + 2 * BAR_LABEL_PAD && segSize >= opts.minSize;
1267
+ const inside =
1268
+ opts.placement === "inside"
1269
+ ? true
1270
+ : opts.placement === "outside"
1271
+ ? false
1272
+ : fits;
1273
+
1274
+ // `u` increases with value: rightward (x) for horizontal bars, upward
1275
+ // (-y) for vertical bars. The label occupies a span of `len` in u —
1276
+ // its text width when horizontal, its line height when vertical.
1277
+ let lo: number;
1278
+ let hi: number;
1279
+ let cross: number;
1280
+ let svgAnchor: "start" | "middle" | "end" = "middle";
1281
+ const segHiU = vertical ? -bar.y : bar.x + bar.w; // high-value edge
1282
+ const segLoU = vertical ? -(bar.y + bar.h) : bar.x; // low-value edge
1283
+ const len = vertical ? fontSize : textLen;
1284
+ cross = vertical ? bar.x + bar.w / 2 : bar.y + bar.h / 2;
1285
+ if (!inside) {
1286
+ // Just past the high-value edge, growing outward.
1287
+ lo = segHiU + BAR_LABEL_PAD;
1288
+ hi = lo + len;
1289
+ svgAnchor = "start";
1290
+ } else if (opts.align === "start") {
1291
+ lo = segLoU + BAR_LABEL_PAD;
1292
+ hi = lo + len;
1293
+ svgAnchor = "start";
1294
+ } else if (opts.align === "end") {
1295
+ hi = segHiU - BAR_LABEL_PAD;
1296
+ lo = hi - len;
1297
+ svgAnchor = "end";
1298
+ } else {
1299
+ const c = (segHiU + segLoU) / 2;
1300
+ lo = c - len / 2;
1301
+ hi = c + len / 2;
1302
+ svgAnchor = "middle";
1303
+ }
1304
+ drafts.push({
1305
+ bar,
1306
+ text,
1307
+ textLen,
1308
+ inside,
1309
+ cross,
1310
+ lo,
1311
+ hi,
1312
+ svgAnchor,
1313
+ hidden: false,
1314
+ });
1315
+ }
1316
+
1317
+ // Declutter per category along `u`: sweep low→high, pushing or hiding
1318
+ // labels that would overlap the previous one. Bounds keep a pushed label
1319
+ // from sliding off the chart.
1320
+ const uMax = vertical ? -padding.value.top : width.value - 2;
1321
+ const byCategory = new Map<number, Draft[]>();
1322
+ for (const d of drafts) {
1323
+ const arr = byCategory.get(d.bar.categoryIndex);
1324
+ if (arr) arr.push(d);
1325
+ else byCategory.set(d.bar.categoryIndex, [d]);
1326
+ }
1327
+ for (const group of byCategory.values()) {
1328
+ group.sort((a, b) => a.lo - b.lo);
1329
+ let cursor = -Infinity;
1330
+ for (const d of group) {
1331
+ if (d.lo < cursor + BAR_LABEL_GAP) {
1332
+ if (opts.overlap === "hide") {
1333
+ d.hidden = true;
1334
+ continue;
1335
+ }
1336
+ const shift = cursor + BAR_LABEL_GAP - d.lo;
1337
+ d.lo += shift;
1338
+ d.hi += shift;
1339
+ d.inside = false; // a shifted label no longer sits in its segment
1340
+ d.svgAnchor = "start";
1341
+ }
1342
+ if (d.hi > uMax) {
1343
+ d.hidden = true;
1344
+ continue;
1345
+ }
1346
+ cursor = d.hi;
1347
+ }
1348
+ }
1349
+
1350
+ const auto = opts.color === "auto";
1351
+ const items: BarLabelItem[] = [];
1352
+ for (const d of drafts) {
1353
+ if (d.hidden) continue;
1354
+ let x: number;
1355
+ let y: number;
1356
+ let anchor: "start" | "middle" | "end";
1357
+ if (vertical) {
1358
+ // Vertical bars center the text horizontally; align shifted the
1359
+ // [lo, hi] band along the value (y) axis, so render at its center.
1360
+ x = d.cross;
1361
+ y = -((d.lo + d.hi) / 2);
1362
+ anchor = "middle";
1363
+ } else {
1364
+ anchor = d.svgAnchor;
1365
+ x =
1366
+ anchor === "start" ? d.lo : anchor === "end" ? d.hi : (d.lo + d.hi) / 2;
1367
+ y = d.cross;
1368
+ }
1369
+ // Pick contrast against whatever the label actually sits on. Inside
1370
+ // labels are on their own segment; an outside/shifted label commonly
1371
+ // lands on a neighboring segment of the same stack — use that
1372
+ // segment's fill, falling back to the chart background (currentColor)
1373
+ // only when the label clears all bars.
1374
+ let fill: string;
1375
+ if (!auto) {
1376
+ fill = opts.color;
1377
+ } else if (d.inside) {
1378
+ fill = pickContrastColor(d.bar.color);
1379
+ } else {
1380
+ const cx = vertical ? d.cross : (d.lo + d.hi) / 2;
1381
+ const cy = vertical ? -((d.lo + d.hi) / 2) : d.cross;
1382
+ const under = barColorAt(d.bar.categoryIndex, cx, cy);
1383
+ fill = under ? pickContrastColor(under) : "currentColor";
1384
+ }
1385
+ items.push({
1386
+ key: `${d.bar.categoryIndex}-${d.bar.seriesIndex}`,
1387
+ x,
1388
+ y,
1389
+ text: d.text,
1390
+ anchor,
1391
+ fill,
1392
+ fontSize,
1393
+ fontWeight: opts.fontWeight,
1394
+ });
1395
+ }
1396
+ return items;
1397
+ });
1398
+
1399
+ /**
1400
+ * Anchor + x for the category labels (and matching `categoryHeader`) in
1401
+ * horizontal orientation, driven by `categoryAlign`. Vertical orientation
1402
+ * doesn't use this (labels stay centered under each column).
1403
+ */
1404
+ const categoryLabelLayout = computed<{
1405
+ anchor: "start" | "middle" | "end";
1406
+ x: number;
1407
+ }>(() => {
1408
+ const right = padding.value.left - 6;
1409
+ switch (props.categoryAlign ?? "end") {
1410
+ case "start":
1411
+ return { anchor: "start", x: CATEGORY_LABEL_INSET };
1412
+ case "center":
1413
+ return { anchor: "middle", x: (CATEGORY_LABEL_INSET + right) / 2 };
1414
+ default:
1415
+ return { anchor: "end", x: right };
1416
+ }
1417
+ });
1418
+
1419
+ interface ColumnHeader {
1420
+ key: string;
1421
+ text: string;
1422
+ x: number;
1423
+ y: number;
1424
+ anchor: "start" | "middle" | "end";
1425
+ }
1426
+
1427
+ /**
1428
+ * Positioned column headers (`categoryHeader` / `valueHeader`), drawn in
1429
+ * the reserved row just above the plot. The category header is aligned to
1430
+ * match the category labels; the value header starts where the bars do.
1431
+ */
1432
+ const columnHeaders = computed<ColumnHeader[]>(() => {
1433
+ const out: ColumnHeader[] = [];
1434
+ const y = padding.value.top - 7;
1435
+ if (props.valueHeader) {
1436
+ out.push({
1437
+ key: "vh",
1438
+ text: props.valueHeader,
1439
+ x: padding.value.left,
1440
+ y,
1441
+ anchor: "start",
1442
+ });
1443
+ }
1444
+ if (props.categoryHeader) {
1445
+ const horizontal = !isVertical.value;
1446
+ const cat = categoryLabelLayout.value;
1447
+ out.push({
1448
+ key: "ch",
1449
+ text: props.categoryHeader,
1450
+ x: horizontal ? cat.x : padding.value.left,
1451
+ y,
1452
+ anchor: horizontal ? cat.anchor : "start",
1453
+ });
1454
+ }
1455
+ return out;
1456
+ });
1021
1457
  </script>
1022
1458
 
1023
1459
  <template>
@@ -1050,6 +1486,20 @@ const positionedLegendItems = computed(() => {
1050
1486
  {{ line }}
1051
1487
  </tspan>
1052
1488
  </text>
1489
+ <!-- column headers (category / value), in the reserved row above the plot -->
1490
+ <text
1491
+ v-for="h in columnHeaders"
1492
+ :key="h.key"
1493
+ :data-testid="h.key === 'ch' ? 'category-header' : 'value-header'"
1494
+ :x="h.x"
1495
+ :y="h.y"
1496
+ :text-anchor="h.anchor"
1497
+ :font-size="axisLabelResolved.fontSize"
1498
+ :fill="axisLabelResolved.fill"
1499
+ font-weight="600"
1500
+ >
1501
+ {{ h.text }}
1502
+ </text>
1053
1503
  <!-- inline legend -->
1054
1504
  <g v-if="positionedLegendItems.length > 0">
1055
1505
  <template v-for="(item, i) in positionedLegendItems" :key="'ileg' + i">
@@ -1082,8 +1532,9 @@ const positionedLegendItems = computed(() => {
1082
1532
  </text>
1083
1533
  </template>
1084
1534
  </g>
1085
- <!-- axes -->
1535
+ <!-- axes (the value axis line is suppressed when valueAxis is false) -->
1086
1536
  <line
1537
+ v-if="!isVertical || valueAxis"
1087
1538
  :x1="snap(padding.left)"
1088
1539
  :y1="snap(padding.top)"
1089
1540
  :x2="snap(padding.left)"
@@ -1092,6 +1543,7 @@ const positionedLegendItems = computed(() => {
1092
1543
  stroke-opacity="0.3"
1093
1544
  />
1094
1545
  <line
1546
+ v-if="isVertical || valueAxis"
1095
1547
  :x1="snap(padding.left)"
1096
1548
  :y1="snap(padding.top + innerH)"
1097
1549
  :x2="snap(padding.left + innerW)"
@@ -1100,7 +1552,7 @@ const positionedLegendItems = computed(() => {
1100
1552
  stroke-opacity="0.3"
1101
1553
  />
1102
1554
  <!-- value grid lines -->
1103
- <template v-if="valueGrid">
1555
+ <template v-if="valueGrid && valueAxis">
1104
1556
  <line
1105
1557
  v-for="(tick, i) in valueTickItems"
1106
1558
  :key="'vg' + i"
@@ -1124,7 +1576,7 @@ const positionedLegendItems = computed(() => {
1124
1576
  pointer-events="none"
1125
1577
  />
1126
1578
  <!-- value tick labels -->
1127
- <template v-if="isVertical">
1579
+ <template v-if="valueAxis && isVertical">
1128
1580
  <text
1129
1581
  v-for="(tick, i) in valueTickItems"
1130
1582
  :key="'vt' + i"
@@ -1141,7 +1593,7 @@ const positionedLegendItems = computed(() => {
1141
1593
  {{ tick.value }}
1142
1594
  </text>
1143
1595
  </template>
1144
- <template v-else>
1596
+ <template v-else-if="valueAxis">
1145
1597
  <text
1146
1598
  v-for="(tick, i) in valueTickItems"
1147
1599
  :key="'vt' + i"
@@ -1192,9 +1644,9 @@ const positionedLegendItems = computed(() => {
1192
1644
  v-for="(tick, i) in categoryTickItems"
1193
1645
  :key="'ct' + i"
1194
1646
  data-testid="category-tick"
1195
- :x="padding.left - 6"
1647
+ :x="categoryLabelLayout.x"
1196
1648
  :y="tick.pos"
1197
- text-anchor="end"
1649
+ :text-anchor="categoryLabelLayout.anchor"
1198
1650
  dominant-baseline="middle"
1199
1651
  :font-size="tickLabelResolved.fontSize"
1200
1652
  :fill="tickLabelResolved.fill"
@@ -1231,6 +1683,22 @@ const positionedLegendItems = computed(() => {
1231
1683
  :fill-opacity="bar.opacity"
1232
1684
  :style="bar.blendMode ? { mixBlendMode: bar.blendMode } : undefined"
1233
1685
  />
1686
+ <!-- value-on-bar labels -->
1687
+ <text
1688
+ v-for="item in barLabelItems"
1689
+ :key="'blbl' + item.key"
1690
+ data-testid="bar-label"
1691
+ :x="item.x"
1692
+ :y="item.y"
1693
+ :text-anchor="item.anchor"
1694
+ dominant-baseline="middle"
1695
+ :font-size="item.fontSize"
1696
+ :font-weight="item.fontWeight"
1697
+ :fill="item.fill"
1698
+ pointer-events="none"
1699
+ >
1700
+ {{ item.text }}
1701
+ </text>
1234
1702
  <!-- summary lines (drawn above bars, below annotations) -->
1235
1703
  <template v-for="(line, i) in summaryLinesResolved" :key="'sl' + i">
1236
1704
  <path
@@ -124,6 +124,56 @@ e.g. `"percent:1"`), a printf-style format string, or a function
124
124
  </template>
125
125
  </ComponentDemo>
126
126
 
127
+ ### Wrapping long content
128
+
129
+ By default, cell content stays on one line and truncates with an ellipsis
130
+ when it's wider than the column — so a long string in one column can't
131
+ spill into the next. Set `wrap: true` on a column to let it grow
132
+ vertically instead.
133
+
134
+ <ComponentDemo>
135
+ <DataTable
136
+ :data="{
137
+ drug: ['Compound A', 'Compound B', 'Compound C'],
138
+ note: [
139
+ 'Well tolerated at all tested doses; no adverse events reported.',
140
+ 'Mild GI symptoms in 4% of participants; resolved without intervention.',
141
+ 'Discontinued at week 6 after liver-enzyme elevation in two participants.',
142
+ ],
143
+ }"
144
+ :column-config="{
145
+ drug: { width: 'small' },
146
+ note: { wrap: true, width: 'large' },
147
+ }"
148
+ />
149
+
150
+ <template #code>
151
+
152
+ ```vue
153
+ <DataTable
154
+ :data="{
155
+ drug: ['Compound A', 'Compound B', 'Compound C'],
156
+ note: [
157
+ 'Well tolerated at all tested doses; no adverse events reported.',
158
+ 'Mild GI symptoms in 4% of participants; resolved without intervention.',
159
+ 'Discontinued at week 6 after liver-enzyme elevation in two participants.',
160
+ ],
161
+ }"
162
+ :column-config="{
163
+ drug: { width: 'small' },
164
+ note: { wrap: true, width: 'large' },
165
+ }"
166
+ />
167
+ ```
168
+
169
+ </template>
170
+ </ComponentDemo>
171
+
172
+ Use `columnClass` to attach a class to both the header and every body
173
+ cell in a column — handy when whole-column styling (background, border,
174
+ font weight) should match across the header and data. `cellClass` keeps
175
+ its meaning (body cells only).
176
+
127
177
  ### Full width
128
178
 
129
179
  By default the table sizes to its content (columns default to a fixed
@@ -315,7 +365,12 @@ interface ColumnConfig {
315
365
  label?: string;
316
366
  width?: "small" | "medium" | "large" | number;
317
367
  align?: "left" | "center" | "right";
368
+ /** Class applied to body `<td>` cells only. */
318
369
  cellClass?: string;
370
+ /** Class applied to both the header `<th>` and body `<td>` cells. */
371
+ columnClass?: string;
372
+ /** Allow cell contents to wrap. Default `false` (nowrap + ellipsis). */
373
+ wrap?: boolean;
319
374
  format?: NumberFormat | ((value: CellValue, row: number) => string);
320
375
  }
321
376