@cfasim-ui/docs 0.6.2 → 0.6.4

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.
@@ -699,6 +699,29 @@ anchor. `lineColor`, `lineWidth`, and `lineDash` style the line.
699
699
  </template>
700
700
  </ComponentDemo>
701
701
 
702
+ ## Accessibility
703
+
704
+ The chart's `<svg>` is exposed as a single labeled image so screen readers
705
+ announce one accessible name instead of wandering through the individual bars.
706
+ When the chart has a `title`, the `<svg>` gets `role="img"` and an `aria-label`
707
+ set to the title. The menu and download controls live outside the `<svg>`, so
708
+ they stay reachable regardless.
709
+
710
+ Set `ariaLabel` to give screen readers a fuller summary than the visible title
711
+ (it overrides `title` for the accessible name only):
712
+
713
+ ```vue
714
+ <BarChart
715
+ :data="cases"
716
+ :categories="regions"
717
+ title="Cases by region"
718
+ aria-label="Cases by region: North 120, South 80, West 45"
719
+ />
720
+ ```
721
+
722
+ Pass `role` to override the default `"img"` (e.g. `role="figure"`, or a role of
723
+ your own).
724
+
702
725
  ## API
703
726
 
704
727
  ## Props
@@ -709,6 +732,8 @@ anchor. `lineColor`, `lineWidth`, and `lineDash` style the line.
709
732
  | `height` | `number` | No | — |
710
733
  | `title` | `string` | No | — |
711
734
  | `titleStyle` | `TitleStyle` | No | — |
735
+ | `role` | `string` | No | — |
736
+ | `ariaLabel` | `string` | No | — |
712
737
  | `axisLabelStyle` | `LabelStyle` | No | — |
713
738
  | `tickLabelStyle` | `LabelStyle` | No | — |
714
739
  | `legendStyle` | `LabelStyle` | No | — |
@@ -286,6 +286,16 @@ const props = withDefaults(defineProps<BarChartProps>(), {
286
286
  valueAxis: true,
287
287
  });
288
288
 
289
+ // Accessible name for the chart; falls back to the visible title.
290
+ const chartAriaLabel = computed(() => props.ariaLabel ?? props.title);
291
+ // Expose the <svg> as a single labeled image so screen readers announce the
292
+ // name instead of wandering through the individual bars. The menu/download
293
+ // controls live outside the <svg>, so they stay reachable. An explicit `role`
294
+ // prop always wins.
295
+ const chartRole = computed(
296
+ () => props.role ?? (chartAriaLabel.value ? "img" : undefined),
297
+ );
298
+
289
299
  // The template root is a <Teleport>, so fallthrough attrs (class, style,
290
300
  // data-*, id…) can't auto-inherit — forward them onto the wrapper manually.
291
301
  defineOptions({ inheritAttrs: false });
@@ -1485,7 +1495,13 @@ const columnHeaders = computed<ColumnHeader[]>(() => {
1485
1495
  <div class="chart-sr-only" aria-live="polite">
1486
1496
  {{ isFullscreen ? "Chart expanded to fill window" : "" }}
1487
1497
  </div>
1488
- <svg ref="svgRef" :width="width" :height="height">
1498
+ <svg
1499
+ ref="svgRef"
1500
+ :width="width"
1501
+ :height="height"
1502
+ :role="chartRole || undefined"
1503
+ :aria-label="chartAriaLabel || undefined"
1504
+ >
1489
1505
  <!-- title -->
1490
1506
  <text
1491
1507
  v-if="title"
@@ -745,6 +745,30 @@ set `tooltip-trigger`.
745
745
  </template>
746
746
  </ComponentDemo>
747
747
 
748
+ ## Accessibility
749
+
750
+ The map's individual regions aren't exposed to assistive tech, so the map
751
+ announces itself with a single accessible name. When it has a `title`, the root
752
+ element gets `role="figure"` and an `aria-label` set to the title, so screen
753
+ readers announce it as a labeled figure while the menu and reset controls stay
754
+ reachable.
755
+
756
+ Set `ariaLabel` to give screen readers a fuller summary than the visible title
757
+ (it overrides `title` for the accessible name only):
758
+
759
+ ```vue
760
+ <ChoroplethMap
761
+ :topology="usStates"
762
+ :data="cases"
763
+ title="Cases by state"
764
+ aria-label="US map shaded by case count per state, highest in the Southeast"
765
+ />
766
+ ```
767
+
768
+ Pass `role` to override the default, e.g. `role="img"` to expose the map as a
769
+ single image (note this hides the inner menu/reset controls from assistive
770
+ tech).
771
+
748
772
  ## Props
749
773
 
750
774
  | Prop | Type | Required | Default |
@@ -759,6 +783,8 @@ set `tooltip-trigger`.
759
783
  | `colorScale` | `ChoroplethColorScale \| ThresholdStop[] \| CategoricalStop[]` | No | — |
760
784
  | `title` | `string` | No | — |
761
785
  | `titleStyle` | `TitleStyle` | No | — |
786
+ | `role` | `string` | No | — |
787
+ | `ariaLabel` | `string` | No | — |
762
788
  | `legendStyle` | `LabelStyle` | No | — |
763
789
  | `noDataColor` | `string` | No | `"#ddd"` |
764
790
  | `strokeColor` | `string` | No | `"#fff"` |
@@ -126,6 +126,21 @@ const props = withDefaults(
126
126
  title?: string;
127
127
  /** Styling for the map title. See `TitleStyle`. */
128
128
  titleStyle?: TitleStyle;
129
+ /**
130
+ * ARIA role for the map's root element. Defaults to `"figure"` when an
131
+ * accessible name is present (from `ariaLabel` or `title`), so screen
132
+ * readers announce the map as a labeled figure while its controls (menu,
133
+ * reset) stay reachable. Pass `"img"` to expose it as a single image
134
+ * (which hides the inner controls from assistive tech), or your own role.
135
+ */
136
+ role?: string;
137
+ /**
138
+ * Accessible name for the map, announced by screen readers via the root
139
+ * element's `aria-label`. Defaults to the `title` prop. The individual
140
+ * regions aren't exposed to assistive tech, so set this to a short summary
141
+ * of what the map shows.
142
+ */
143
+ ariaLabel?: string;
129
144
  /** Styling for the legend (title, swatch labels, and continuous-scale ticks). */
130
145
  legendStyle?: LabelStyle;
131
146
  noDataColor?: string;
@@ -225,6 +240,14 @@ const props = withDefaults(
225
240
  },
226
241
  );
227
242
 
243
+ // Accessible name for the whole map; falls back to the visible title.
244
+ const chartAriaLabel = computed(() => props.ariaLabel ?? props.title);
245
+ // Label the map as a figure when it has a name (keeps inner controls
246
+ // reachable, unlike role="img"). An explicit `role` prop always wins.
247
+ const chartRole = computed(
248
+ () => props.role ?? (chartAriaLabel.value ? "figure" : undefined),
249
+ );
250
+
228
251
  // The template root is a <Teleport>, so fallthrough attrs (class, style,
229
252
  // data-*, id…) can't auto-inherit — forward them onto the wrapper manually.
230
253
  defineOptions({ inheritAttrs: false });
@@ -1694,6 +1717,8 @@ watch(
1694
1717
  { pannable: pan, 'is-fullscreen': fullscreen.isFullscreen.value },
1695
1718
  ]"
1696
1719
  :style="fullscreen.fullscreenStyle.value"
1720
+ :role="chartRole || undefined"
1721
+ :aria-label="chartAriaLabel || undefined"
1697
1722
  >
1698
1723
  <ChartMenu
1699
1724
  v-if="menu"
@@ -888,6 +888,56 @@ Markers compose (`**_bold italic_**`).
888
888
  </template>
889
889
  </ComponentDemo>
890
890
 
891
+ Multi-line callouts attach to whichever edge of the text faces the anchor:
892
+ text **above** the point connects to its bottom edge, text **below**
893
+ connects to its top, and text to the side connects to its near vertical
894
+ edge — so the pointer never cuts through the lines. The demo below anchors
895
+ the same two-line label at one point and fans it out in every direction.
896
+
897
+ <ComponentDemo>
898
+ <LineChart
899
+ :data="[2, 10, 7, 6, 8, 4, 9]"
900
+ :annotations="[
901
+ { x: 3, y: 6, offset: { x: 0, y: -86 }, text: 'Cluster\ndetected' },
902
+ { x: 3, y: 6, offset: { x: 62, y: -62 }, text: 'Cluster\ndetected' },
903
+ { x: 3, y: 6, offset: { x: 96, y: 0 }, text: 'Cluster\ndetected' },
904
+ { x: 3, y: 6, offset: { x: 62, y: 62 }, text: 'Cluster\ndetected' },
905
+ { x: 3, y: 6, offset: { x: 0, y: 86 }, text: 'Cluster\ndetected' },
906
+ { x: 3, y: 6, offset: { x: -62, y: 62 }, text: 'Cluster\ndetected' },
907
+ { x: 3, y: 6, offset: { x: -96, y: 0 }, text: 'Cluster\ndetected' },
908
+ { x: 3, y: 6, offset: { x: -62, y: -62 }, text: 'Cluster\ndetected' },
909
+ ]"
910
+ :chart-padding="{ top: 70, right: 120, bottom: 50, left: 120 }"
911
+ :height="340"
912
+ x-label="Days"
913
+ y-label="Cases"
914
+ />
915
+
916
+ <template #code>
917
+
918
+ ```vue
919
+ <LineChart
920
+ :data="[2, 10, 7, 6, 8, 4, 9]"
921
+ :annotations="[
922
+ { x: 3, y: 6, offset: { x: 0, y: -86 }, text: 'Cluster\ndetected' },
923
+ { x: 3, y: 6, offset: { x: 62, y: -62 }, text: 'Cluster\ndetected' },
924
+ { x: 3, y: 6, offset: { x: 96, y: 0 }, text: 'Cluster\ndetected' },
925
+ { x: 3, y: 6, offset: { x: 62, y: 62 }, text: 'Cluster\ndetected' },
926
+ { x: 3, y: 6, offset: { x: 0, y: 86 }, text: 'Cluster\ndetected' },
927
+ { x: 3, y: 6, offset: { x: -62, y: 62 }, text: 'Cluster\ndetected' },
928
+ { x: 3, y: 6, offset: { x: -96, y: 0 }, text: 'Cluster\ndetected' },
929
+ { x: 3, y: 6, offset: { x: -62, y: -62 }, text: 'Cluster\ndetected' },
930
+ ]"
931
+ :chart-padding="{ top: 70, right: 120, bottom: 50, left: 120 }"
932
+ :height="340"
933
+ x-label="Days"
934
+ y-label="Cases"
935
+ />
936
+ ```
937
+
938
+ </template>
939
+ </ComponentDemo>
940
+
891
941
  ```ts
892
942
  interface ChartAnnotation {
893
943
  x: number; // anchor in data coords (x-axis)
@@ -1063,6 +1113,28 @@ until the user clicks Download:
1063
1113
  <LineChart :data="cases" :csv="() => buildCsv(cases, dates)" />
1064
1114
  ```
1065
1115
 
1116
+ ## Accessibility
1117
+
1118
+ The chart's `<svg>` is exposed as a single labeled image so screen readers
1119
+ announce one accessible name instead of wandering through the individual marks.
1120
+ When the chart has a `title`, the `<svg>` gets `role="img"` and an `aria-label`
1121
+ set to the title. The menu and download controls live outside the `<svg>`, so
1122
+ they stay reachable regardless.
1123
+
1124
+ Set `ariaLabel` to give screen readers a fuller summary than the visible title
1125
+ (it overrides `title` for the accessible name only):
1126
+
1127
+ ```vue
1128
+ <LineChart
1129
+ :data="cases"
1130
+ title="Daily ED visits"
1131
+ aria-label="Daily ED visits over 11 days, rising from 12 to a peak of 48"
1132
+ />
1133
+ ```
1134
+
1135
+ Pass `role` to override the default `"img"` (e.g. `role="figure"`, or a role of
1136
+ your own).
1137
+
1066
1138
  ## Props
1067
1139
 
1068
1140
  | Prop | Type | Required | Default |
@@ -1071,6 +1143,8 @@ until the user clicks Download:
1071
1143
  | `height` | `number` | No | — |
1072
1144
  | `title` | `string` | No | — |
1073
1145
  | `titleStyle` | `TitleStyle` | No | — |
1146
+ | `role` | `string` | No | — |
1147
+ | `ariaLabel` | `string` | No | — |
1074
1148
  | `axisLabelStyle` | `LabelStyle` | No | — |
1075
1149
  | `tickLabelStyle` | `LabelStyle` | No | — |
1076
1150
  | `legendStyle` | `LabelStyle` | No | — |
@@ -240,6 +240,16 @@ const props = withDefaults(defineProps<LineChartProps>(), {
240
240
  yScaleType: "linear",
241
241
  });
242
242
 
243
+ // Accessible name for the chart; falls back to the visible title.
244
+ const chartAriaLabel = computed(() => props.ariaLabel ?? props.title);
245
+ // Expose the <svg> as a single labeled image so screen readers announce the
246
+ // name instead of wandering through the individual marks. The menu/download
247
+ // controls live outside the <svg>, so they stay reachable. An explicit `role`
248
+ // prop always wins.
249
+ const chartRole = computed(
250
+ () => props.role ?? (chartAriaLabel.value ? "img" : undefined),
251
+ );
252
+
243
253
  // The template root is a <Teleport>, so fallthrough attrs (class, style,
244
254
  // data-*, id…) can't auto-inherit — forward them onto the wrapper manually.
245
255
  defineOptions({ inheritAttrs: false });
@@ -1058,7 +1068,13 @@ const positionedLegendItems = computed(() => {
1058
1068
  <div class="chart-sr-only" aria-live="polite">
1059
1069
  {{ isFullscreen ? "Chart expanded to fill window" : "" }}
1060
1070
  </div>
1061
- <svg ref="svgRef" :width="width" :height="totalHeight">
1071
+ <svg
1072
+ ref="svgRef"
1073
+ :width="width"
1074
+ :height="totalHeight"
1075
+ :role="chartRole || undefined"
1076
+ :aria-label="chartAriaLabel || undefined"
1077
+ >
1062
1078
  <!-- title -->
1063
1079
  <text
1064
1080
  v-if="title"
@@ -36,9 +36,9 @@ const ANCHOR_GAP_PX = 4;
36
36
  const LABEL_GAP_PX = 6;
37
37
  const LINE_HEIGHT_RATIO = 1.2;
38
38
  // Ratio of font-size that puts the pointer endpoint at the visual center
39
- // of the first text line (between baseline and cap-height). Lands on the
40
- // x-height middle for most fonts.
41
- const FIRST_LINE_CENTER_RATIO = 0.35;
39
+ // of a text line (between baseline and cap-height). Lands on the x-height
40
+ // middle for most fonts.
41
+ const LINE_CENTER_RATIO = 0.35;
42
42
 
43
43
  interface TextRun {
44
44
  text: string;
@@ -139,6 +139,8 @@ const items = computed<RenderedAnnotation[]>(() => {
139
139
  a.align ?? (offsetX > 0 ? "left" : offsetX < 0 ? "right" : "center");
140
140
  const textAnchor = alignMap[align];
141
141
 
142
+ const lines = a.text.split("\n").map(parseInline);
143
+
142
144
  let rule: RenderedAnnotation["rule"];
143
145
  let pointerPath = "";
144
146
  let arrowTip: RenderedAnnotation["arrowTip"];
@@ -152,6 +154,7 @@ const items = computed<RenderedAnnotation[]>(() => {
152
154
  labelX,
153
155
  labelY,
154
156
  fontSize,
157
+ lines.length,
155
158
  pointer as "curved" | "straight" | "none",
156
159
  );
157
160
  pointerPath = built.path;
@@ -159,7 +162,7 @@ const items = computed<RenderedAnnotation[]>(() => {
159
162
  }
160
163
 
161
164
  out.push({
162
- lines: a.text.split("\n").map(parseInline),
165
+ lines,
163
166
  textX: labelX,
164
167
  textY: labelY,
165
168
  textAnchor,
@@ -220,12 +223,17 @@ function computeRule(
220
223
  * - When the label has offset in only one dimension, the line is
221
224
  * straight (vertical or horizontal) ending at the label's baseline.
222
225
  * - When both dimensions have offset, the line is a quarter-arc:
223
- * a quadratic Bezier with the control point at `(anchorX, firstLineY)`
224
- * where `firstLineY` is the visual center of the first line of text
225
- * (slightly above the baseline). The curve emerges from the anchor
226
- * vertically toward the label's row, then bends horizontally into the
227
- * label so the endpoint reads as pointing at the first line — not at
228
- * the bottom of a multi-line block.
226
+ * a quadratic Bezier with the control point at `(anchorX, targetY)`
227
+ * where `targetY` is the visual center of the text line nearest the
228
+ * anchor (slightly above its baseline). The curve emerges from the
229
+ * anchor vertically toward the label's row, then bends horizontally
230
+ * into the label.
231
+ *
232
+ * `targetY` tracks the *near* edge of the text block: the first line
233
+ * when the label sits below the anchor (connector points up at the top
234
+ * line), the last line when the label sits above the anchor (connector
235
+ * points up at the bottom line) — so multi-line text above the point
236
+ * connects to its bottom edge instead of having the line cut through it.
229
237
  */
230
238
  interface PointerGeom {
231
239
  path: string;
@@ -242,23 +250,30 @@ function buildPointerPath(
242
250
  lx: number,
243
251
  ly: number,
244
252
  fontSize: number,
253
+ lineCount: number,
245
254
  pointer: "curved" | "straight" | "none",
246
255
  ): PointerGeom {
247
256
  if (pointer === "none") return { path: "" };
248
257
  const dx = lx - ax;
249
258
  const dy = ly - ay;
250
259
 
251
- // Target the visual center of the first line so multi-line text
252
- // doesn't have the pointer dive below the whole block.
253
- const targetY = ly - fontSize * FIRST_LINE_CENTER_RATIO;
260
+ // Aim at the visual center of the text line nearest the anchor: the
261
+ // first (top) line when the label is below the anchor, the last
262
+ // (bottom) line when the label is above it. That keeps the connector
263
+ // attached to the block's near edge instead of cutting through the
264
+ // lower lines of text that sits above the point.
265
+ const lineHeight = fontSize * LINE_HEIGHT_RATIO;
266
+ const nearestBaseline = dy < 0 ? ly + (lineCount - 1) * lineHeight : ly;
267
+ const targetY = nearestBaseline - fontSize * LINE_CENTER_RATIO;
254
268
 
255
269
  // Pure horizontal or vertical → straight line at baseline, no curve.
256
270
  // Force straight when explicitly requested.
257
271
  if (dx === 0 || dy === 0 || pointer === "straight") {
258
- // For straight pointers, aim at first-line-center (so multi-line text
259
- // still points at the first line) — but only when the anchor isn't
260
- // already at exactly the same Y (pure horizontal offset). The pure
261
- // horizontal case stays on the baseline so it's a real horizontal line.
272
+ // For straight pointers, aim at the near-line center (so multi-line
273
+ // text above the point connects to its bottom edge) — but only when
274
+ // the anchor isn't already at exactly the same Y (pure horizontal
275
+ // offset). The pure horizontal case stays on the baseline so it's a
276
+ // real horizontal line.
262
277
  const ey = dy === 0 ? ly : targetY;
263
278
  const segDx = lx - ax;
264
279
  const segDy = ey - ay;
@@ -103,6 +103,21 @@ export interface ChartCommonProps {
103
103
  title?: string;
104
104
  /** Styling for the chart title. See `TitleStyle`. */
105
105
  titleStyle?: TitleStyle;
106
+ /**
107
+ * ARIA role for the chart's `<svg>`. Defaults to `"img"` when an accessible
108
+ * name is present (from `ariaLabel` or `title`), so screen readers announce
109
+ * the chart as a single labeled image instead of exposing the individual
110
+ * marks. The menu and download controls sit outside the `<svg>`, so they
111
+ * stay reachable regardless. Pass your own role to override.
112
+ */
113
+ role?: string;
114
+ /**
115
+ * Accessible name for the chart, announced by screen readers via the
116
+ * `<svg>`'s `aria-label`. Defaults to the `title` prop. The individual
117
+ * marks aren't exposed to assistive tech, so set this to a short summary of
118
+ * what the chart shows.
119
+ */
120
+ ariaLabel?: string;
106
121
  /** Styling for axis labels (the `xLabel` / `yLabel` text). */
107
122
  axisLabelStyle?: LabelStyle;
108
123
  /** Styling for axis tick labels (the numbers/categories on each axis). */
@@ -2,6 +2,8 @@
2
2
 
3
3
  A responsive two-panel layout with a collapsible sidebar and main content area. On mobile, the sidebar becomes an overlay.
4
4
 
5
+ When the sidebar is collapsed it is marked [`inert`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert), so its contents (including the collapse button) are removed from the tab order and the accessibility tree while visually hidden. The expand button is likewise `inert` while the sidebar is open. This keeps keyboard and screen-reader focus on whichever control is actually visible.
6
+
5
7
  ## Demo
6
8
 
7
9
  <a href="/cfa-simulator/docs/demos/sidebar-layout/index.html" target="_blank">Open in full window ↗</a>
@@ -86,7 +86,7 @@ if (globals) {
86
86
  <template>
87
87
  <div class="SidebarLayout" :data-collapsed="collapsed">
88
88
  <div class="SidebarRail">
89
- <aside class="Sidebar">
89
+ <aside class="Sidebar" :inert="collapsed">
90
90
  <div class="SidebarScroll">
91
91
  <div class="SidebarHeader">
92
92
  <button
@@ -105,6 +105,7 @@ if (globals) {
105
105
  <button
106
106
  type="button"
107
107
  class="Toggle Toggle--expand"
108
+ :inert="!collapsed"
108
109
  aria-label="Expand sidebar"
109
110
  title="Expand sidebar"
110
111
  @click="toggle"
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.6.2",
2
+ "version": "0.6.4",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {