@cfasim-ui/docs 0.5.1 → 0.6.1

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.
@@ -19,18 +19,48 @@ const props = withDefaults(
19
19
  items: ChartMenuItem[];
20
20
  /** Force the dropdown style even when only one item is provided. */
21
21
  forceDropdown?: boolean;
22
+ /**
23
+ * When the chart is expanded, the menu trigger is replaced by a
24
+ * persistent close (✕) button that emits `close`.
25
+ */
26
+ isFullscreen?: boolean;
22
27
  }>(),
23
- { forceDropdown: false },
28
+ { forceDropdown: false, isFullscreen: false },
24
29
  );
25
30
 
31
+ const emit = defineEmits<{ (e: "close"): void }>();
32
+
26
33
  const useDropdown = () => props.forceDropdown || props.items.length > 1;
27
34
  </script>
28
35
 
29
36
  <template>
30
- <div class="chart-menu-trigger-area">
37
+ <div
38
+ class="chart-menu-trigger-area"
39
+ :class="{ 'chart-menu-trigger-area--expanded': isFullscreen }"
40
+ >
41
+ <!-- Expanded: a close button replaces the menu trigger -->
42
+ <button
43
+ v-if="isFullscreen"
44
+ class="chart-menu-button chart-close-button"
45
+ aria-label="Collapse"
46
+ @click="emit('close')"
47
+ >
48
+ <svg
49
+ width="16"
50
+ height="16"
51
+ viewBox="0 0 16 16"
52
+ fill="none"
53
+ stroke="currentColor"
54
+ stroke-width="1.75"
55
+ stroke-linecap="round"
56
+ aria-hidden="true"
57
+ >
58
+ <path d="M4 4l8 8M12 4l-8 8" />
59
+ </svg>
60
+ </button>
31
61
  <!-- Single item: plain button -->
32
62
  <button
33
- v-if="!useDropdown()"
63
+ v-else-if="!useDropdown()"
34
64
  class="chart-menu-button chart-menu-single"
35
65
  :aria-label="items[0].label"
36
66
  @click="items[0].action"
@@ -88,11 +118,19 @@ const useDropdown = () => props.forceDropdown || props.items.length > 1;
88
118
  <style scoped>
89
119
  .chart-menu-trigger-area {
90
120
  position: absolute;
91
- top: 0;
92
- right: 0;
121
+ top: 0.5em;
122
+ right: 0.5em;
93
123
  z-index: 1;
94
124
  }
95
125
 
126
+ /* In fullscreen the button anchors to the viewport corner (the wrapper's
127
+ content padding doesn't move an absolutely-positioned child), so inset it
128
+ further for modal-style breathing room. */
129
+ .chart-menu-trigger-area--expanded {
130
+ top: 1.25em;
131
+ right: 1.25em;
132
+ }
133
+
96
134
  .chart-menu-button {
97
135
  display: flex;
98
136
  align-items: center;
@@ -112,6 +150,11 @@ const useDropdown = () => props.forceDropdown || props.items.length > 1;
112
150
  opacity: 1;
113
151
  }
114
152
 
153
+ /* The close button is always visible while the chart is expanded. */
154
+ .chart-close-button {
155
+ opacity: 1;
156
+ }
157
+
115
158
  .chart-menu-button:hover {
116
159
  background: var(--color-bg-1, rgba(0, 0, 0, 0.05));
117
160
  color: var(--color-text);
@@ -119,6 +162,33 @@ const useDropdown = () => props.forceDropdown || props.items.length > 1;
119
162
  </style>
120
163
 
121
164
  <style>
165
+ /* Reveal the menu button when the user hovers/focuses anywhere in the
166
+ chart. These reference the parent wrapper classes, so they can't be
167
+ scoped to ChartMenu. (In fullscreen the menu button is swapped for the
168
+ always-visible close button, so no rule is needed there.) */
169
+ .line-chart-wrapper:hover .chart-menu-button,
170
+ .bar-chart-wrapper:hover .chart-menu-button,
171
+ .choropleth-wrapper:hover .chart-menu-button,
172
+ .line-chart-wrapper:focus-within .chart-menu-button,
173
+ .bar-chart-wrapper:focus-within .chart-menu-button,
174
+ .choropleth-wrapper:focus-within .chart-menu-button {
175
+ opacity: 1;
176
+ }
177
+
178
+ /* Visually-hidden live region used by each chart wrapper to announce
179
+ expansion. Lives here so it ships once with the shared menu. */
180
+ .chart-sr-only {
181
+ position: absolute;
182
+ width: 1px;
183
+ height: 1px;
184
+ padding: 0;
185
+ margin: -1px;
186
+ overflow: hidden;
187
+ clip: rect(0, 0, 0, 0);
188
+ white-space: nowrap;
189
+ border: 0;
190
+ }
191
+
122
192
  .chart-menu-content {
123
193
  z-index: 100;
124
194
  background: var(--color-bg-0);
@@ -365,6 +365,10 @@ the focused feature toggles back off. If a tooltip is configured, focusing
365
365
  shows that feature's tooltip. Users can pan/zoom freely around the focused
366
366
  area; the built-in **Reset** button clears focus and snaps back.
367
367
 
368
+ Selection works on touch too: a single-finger **tap** on a feature emits
369
+ `stateClick` and toggles focus, while drags (pan) and pinches (zoom) are left
370
+ to the map. Hover tooltips stay off on touch for performance.
371
+
368
372
  Counties are tiny without a zoom — focus is a natural fit for drill-down.
369
373
 
370
374
  <ComponentDemo>
@@ -714,6 +718,7 @@ set `tooltip-trigger`.
714
718
  | `tooltipClamp` | `"none" \| "chart" \| "window"` | No | `"chart"` |
715
719
  | `focus` | `FocusValue` | No | — |
716
720
  | `focusZoomLevel` | `number` | No | `4` |
721
+ | `fullscreenTarget` | `string \| HTMLElement` | No | — |
717
722
 
718
723
 
719
724
  ### StateData
@@ -174,6 +174,15 @@ const props = withDefaults(
174
174
  focus?: FocusValue;
175
175
  /** Scale factor applied when `focus` is set. Default: 4 */
176
176
  focusZoomLevel?: number;
177
+ /**
178
+ * Where to teleport the map while expanded (the Expand menu item). A
179
+ * CSS selector or element; defaults to `body`. Moving it to the
180
+ * document root keeps `position: fixed` resolving against the viewport
181
+ * instead of being trapped by an ancestor's `transform`/`filter`/
182
+ * `contain`/`perspective` or stacking context. Set this when your app
183
+ * doesn't mount at the document root.
184
+ */
185
+ fullscreenTarget?: string | HTMLElement;
177
186
  }>(),
178
187
  {
179
188
  geoType: "states",
@@ -189,6 +198,10 @@ const props = withDefaults(
189
198
  },
190
199
  );
191
200
 
201
+ // The template root is a <Teleport>, so fallthrough attrs (class, style,
202
+ // data-*, id…) can't auto-inherit — forward them onto the wrapper manually.
203
+ defineOptions({ inheritAttrs: false });
204
+
192
205
  const emit = defineEmits<{
193
206
  (
194
207
  e: "stateClick",
@@ -308,10 +321,33 @@ let pendingMoveX = 0;
308
321
  let pendingMoveY = 0;
309
322
  let pendingMoveFrame = 0;
310
323
 
324
+ // Touch tap-to-select. Touch devices get no synthesized-click guarantee
325
+ // (iOS treats the first tap on a hover target as a hover, and the d3-zoom
326
+ // touch listeners can swallow the synthetic click), so we resolve taps from
327
+ // the raw touch events ourselves. A gesture counts as a tap when a single
328
+ // finger lifts close to where it landed, soon after — anything longer or
329
+ // draggier is a pan/long-press and is left to d3-zoom.
330
+ const TAP_SLOP = 10; // px of movement allowed between touchstart and touchend
331
+ const TAP_MAX_MS = 600; // longer presses aren't taps
332
+ let tapStart: {
333
+ x: number;
334
+ y: number;
335
+ time: number;
336
+ featId: string | null;
337
+ } | null = null;
338
+
311
339
  function setupInteraction() {
312
- if (isTouchDevice) return;
313
340
  const g = mapGroupRef.value;
314
341
  if (!g) return;
342
+ // Tap-to-select is wired on every device. `touchend` is non-passive so a
343
+ // confirmed tap can preventDefault and suppress the compatibility
344
+ // click/hover the browser would otherwise synthesize (the double-fire and
345
+ // iOS first-tap-hover sources).
346
+ g.addEventListener("touchstart", onTouchStart, { passive: true });
347
+ g.addEventListener("touchend", onTouchEnd);
348
+ g.addEventListener("touchcancel", onTouchCancel, { passive: true });
349
+ // Hover + tooltip stay off on touch (stroke-width churn degrades zoom/pan).
350
+ if (isTouchDevice) return;
315
351
  g.addEventListener("click", onDelegatedEvent);
316
352
  g.addEventListener("mouseover", onDelegatedEvent);
317
353
  g.addEventListener("mousemove", onDelegatedMouseMove);
@@ -321,6 +357,9 @@ function setupInteraction() {
321
357
  function teardownInteraction() {
322
358
  const g = mapGroupRef.value;
323
359
  if (!g) return;
360
+ g.removeEventListener("touchstart", onTouchStart);
361
+ g.removeEventListener("touchend", onTouchEnd);
362
+ g.removeEventListener("touchcancel", onTouchCancel);
324
363
  g.removeEventListener("click", onDelegatedEvent);
325
364
  g.removeEventListener("mouseover", onDelegatedEvent);
326
365
  g.removeEventListener("mousemove", onDelegatedMouseMove);
@@ -1146,6 +1185,18 @@ function eventToFeatureId(target: EventTarget | null): string | null {
1146
1185
  return el ? ((el as HTMLElement).dataset.featId ?? null) : null;
1147
1186
  }
1148
1187
 
1188
+ // Emits stateClick plus the baked-in click-to-focus toggle so
1189
+ // `v-model:focus="ref"` Just Works: selecting the currently focused feature
1190
+ // clears focus (emits null); selecting any other feature emits its id. With
1191
+ // a `focus` array, any selection of a member clears everything — parents
1192
+ // wanting fine-grained multi-select handle merging via `@update:focus`.
1193
+ // Shared by the mouse-click and touch-tap paths.
1194
+ function emitSelection(data: TooltipPayload) {
1195
+ emit("stateClick", { id: data.id, name: data.name, value: data.value });
1196
+ const wasFocused = focusedBaseIds(normalizedFocus.value).has(data.id);
1197
+ emit("update:focus", wasFocused ? null : data.id);
1198
+ }
1199
+
1149
1200
  function onDelegatedEvent(event: Event) {
1150
1201
  if (isZooming) return;
1151
1202
  const me = event as MouseEvent;
@@ -1155,14 +1206,7 @@ function onDelegatedEvent(event: Event) {
1155
1206
  if (!data) return;
1156
1207
  const payload = { id: data.id, name: data.name, value: data.value };
1157
1208
  if (event.type === "click") {
1158
- emit("stateClick", payload);
1159
- // Click-to-focus toggle, baked in so `v-model:focus="ref"` Just Works:
1160
- // clicking the currently focused feature clears focus (emits null);
1161
- // clicking any other feature emits its id. With a `focus` array, any
1162
- // click on a member clears everything — parents wanting fine-grained
1163
- // multi-select handle merging themselves via `@update:focus`.
1164
- const wasFocused = focusedBaseIds(normalizedFocus.value).has(data.id);
1165
- emit("update:focus", wasFocused ? null : data.id);
1209
+ emitSelection(data);
1166
1210
  } else if (event.type === "mouseover") {
1167
1211
  setHover(pathsByFeatureId.get(featId)!);
1168
1212
  if (hasInteractiveTooltip.value)
@@ -1182,6 +1226,44 @@ function onDelegatedMouseOut(event: MouseEvent) {
1182
1226
  clearHover();
1183
1227
  }
1184
1228
 
1229
+ function onTouchStart(event: TouchEvent) {
1230
+ // Single-finger only — a second touch is a pinch-zoom, not a selection.
1231
+ if (event.touches.length !== 1) {
1232
+ tapStart = null;
1233
+ return;
1234
+ }
1235
+ const t = event.touches[0]!;
1236
+ tapStart = {
1237
+ x: t.clientX,
1238
+ y: t.clientY,
1239
+ time: event.timeStamp,
1240
+ featId: eventToFeatureId(event.target),
1241
+ };
1242
+ }
1243
+
1244
+ function onTouchEnd(event: TouchEvent) {
1245
+ const start = tapStart;
1246
+ tapStart = null;
1247
+ // No feature under the initial touch, or a second finger joined in.
1248
+ if (!start || !start.featId || event.touches.length > 0) return;
1249
+ const t = event.changedTouches[0];
1250
+ if (!t) return;
1251
+ // Moved too far (a pan) or held too long (a long-press) → not a tap.
1252
+ if (Math.abs(t.clientX - start.x) > TAP_SLOP) return;
1253
+ if (Math.abs(t.clientY - start.y) > TAP_SLOP) return;
1254
+ if (event.timeStamp - start.time > TAP_MAX_MS) return;
1255
+ const data = tooltipDataById.get(start.featId);
1256
+ if (!data) return;
1257
+ // Suppress the synthetic click/hover the browser would replay from this
1258
+ // tap, so selection fires exactly once and never via iOS's hover-first tap.
1259
+ event.preventDefault();
1260
+ emitSelection(data);
1261
+ }
1262
+
1263
+ function onTouchCancel() {
1264
+ tapStart = null;
1265
+ }
1266
+
1185
1267
  // ─── Imperative SVG path management ──────────────────────────────────────
1186
1268
  //
1187
1269
  // 3,000+ counties are too many to round-trip through Vue's render scheduler
@@ -1372,7 +1454,9 @@ const gradientCss = computed(() => {
1372
1454
  return `linear-gradient(to right, ${stops})`;
1373
1455
  });
1374
1456
 
1375
- const fullscreen = useChartFullscreen();
1457
+ const fullscreen = useChartFullscreen({
1458
+ target: () => props.fullscreenTarget,
1459
+ });
1376
1460
 
1377
1461
  const menuItems = computed<ChartMenuItem[]>(() => {
1378
1462
  const fname = menuFilename();
@@ -1439,70 +1523,81 @@ watch(
1439
1523
  </script>
1440
1524
 
1441
1525
  <template>
1442
- <div
1443
- ref="containerRef"
1444
- :class="[
1445
- 'choropleth-wrapper',
1446
- { pannable: pan, 'is-fullscreen': fullscreen.isFullscreen.value },
1447
- ]"
1526
+ <Teleport
1527
+ :to="fullscreen.teleportTarget.value"
1528
+ :disabled="!fullscreen.isFullscreen.value"
1448
1529
  >
1449
- <ChartMenu v-if="menu" :items="menuItems" />
1450
- <div class="chart-sr-only" aria-live="polite">
1451
- {{ fullscreen.isFullscreen.value ? "Map expanded to fill window" : "" }}
1452
- </div>
1453
- <!--
1530
+ <div
1531
+ ref="containerRef"
1532
+ v-bind="$attrs"
1533
+ :class="[
1534
+ 'choropleth-wrapper',
1535
+ { pannable: pan, 'is-fullscreen': fullscreen.isFullscreen.value },
1536
+ ]"
1537
+ :style="fullscreen.fullscreenStyle.value"
1538
+ >
1539
+ <ChartMenu
1540
+ v-if="menu"
1541
+ :items="menuItems"
1542
+ :is-fullscreen="fullscreen.isFullscreen.value"
1543
+ @close="fullscreen.exit"
1544
+ />
1545
+ <div class="chart-sr-only" aria-live="polite">
1546
+ {{ fullscreen.isFullscreen.value ? "Map expanded to fill window" : "" }}
1547
+ </div>
1548
+ <!--
1454
1549
  Title + legend live as an HTML overlay on top of the SVG so they keep
1455
1550
  their intrinsic px sizes regardless of how the browser scales the
1456
1551
  viewBox to fit the container.
1457
1552
  -->
1458
- <div v-if="title || showLegend" class="choropleth-header">
1459
- <div v-if="title" class="choropleth-title" :style="titleInlineStyle">
1460
- {{ title }}
1461
- </div>
1462
- <div
1463
- v-if="showLegend"
1464
- class="choropleth-legend"
1465
- :style="legendInlineStyle"
1466
- >
1467
- <span v-if="legendTitle" class="choropleth-legend-title">
1468
- {{ legendTitle }}
1469
- </span>
1470
- <template v-if="isCategorical || isThreshold">
1471
- <span
1472
- v-for="item in discreteLegendItems"
1473
- :key="item.key"
1474
- class="choropleth-legend-item"
1475
- >
1476
- <span
1477
- class="choropleth-legend-swatch"
1478
- :style="{ background: item.color }"
1479
- />
1480
- {{ item.label }}
1553
+ <div v-if="title || showLegend" class="choropleth-header">
1554
+ <div v-if="title" class="choropleth-title" :style="titleInlineStyle">
1555
+ {{ title }}
1556
+ </div>
1557
+ <div
1558
+ v-if="showLegend"
1559
+ class="choropleth-legend"
1560
+ :style="legendInlineStyle"
1561
+ >
1562
+ <span v-if="legendTitle" class="choropleth-legend-title">
1563
+ {{ legendTitle }}
1481
1564
  </span>
1482
- </template>
1483
- <div v-else class="choropleth-legend-continuous">
1484
- <div
1485
- class="choropleth-legend-gradient"
1486
- :style="{ background: gradientCss }"
1487
- />
1488
- <div class="choropleth-legend-ticks">
1565
+ <template v-if="isCategorical || isThreshold">
1489
1566
  <span
1490
- v-for="tick in continuousTicks"
1491
- :key="tick.value"
1492
- :style="{ left: tick.pct + '%' }"
1567
+ v-for="item in discreteLegendItems"
1568
+ :key="item.key"
1569
+ class="choropleth-legend-item"
1493
1570
  >
1494
- {{ tick.value }}
1571
+ <span
1572
+ class="choropleth-legend-swatch"
1573
+ :style="{ background: item.color }"
1574
+ />
1575
+ {{ item.label }}
1495
1576
  </span>
1577
+ </template>
1578
+ <div v-else class="choropleth-legend-continuous">
1579
+ <div
1580
+ class="choropleth-legend-gradient"
1581
+ :style="{ background: gradientCss }"
1582
+ />
1583
+ <div class="choropleth-legend-ticks">
1584
+ <span
1585
+ v-for="tick in continuousTicks"
1586
+ :key="tick.value"
1587
+ :style="{ left: tick.pct + '%' }"
1588
+ >
1589
+ {{ tick.value }}
1590
+ </span>
1591
+ </div>
1496
1592
  </div>
1497
1593
  </div>
1498
1594
  </div>
1499
- </div>
1500
- <svg
1501
- ref="svgRef"
1502
- :viewBox="`0 0 ${width} ${height}`"
1503
- preserveAspectRatio="xMidYMid meet"
1504
- >
1505
- <!--
1595
+ <svg
1596
+ ref="svgRef"
1597
+ :viewBox="`0 0 ${width} ${height}`"
1598
+ preserveAspectRatio="xMidYMid meet"
1599
+ >
1600
+ <!--
1506
1601
  Path elements are created imperatively in `rebuildPaths()`; Vue never
1507
1602
  diffs the per-feature subtree so reactive state changes don't walk
1508
1603
  thousands of vnodes. `mapGroupRef` carries the zoom transform;
@@ -1511,32 +1606,33 @@ watch(
1511
1606
  focus-overlay layer (separate group so hover-raised base paths
1512
1607
  can't cover an overlay).
1513
1608
  -->
1514
- <g ref="mapGroupRef">
1515
- <g ref="baseGroupRef" />
1516
- <g ref="overlayGroupRef" />
1517
- </g>
1518
- </svg>
1519
- <button
1520
- v-if="isZoomed"
1521
- type="button"
1522
- class="choropleth-reset"
1523
- aria-label="Reset zoom"
1524
- @click="resetZoom"
1525
- >
1526
- Reset
1527
- </button>
1528
- <ChoroplethTooltip v-if="hasInteractiveTooltip" ref="tooltipChildRef">
1529
- <template #default="raw">
1530
- <slot name="tooltip" v-bind="narrowSlotProps(raw)">
1531
- <span v-if="tooltipFormat" v-html="tooltipFormat(raw)" />
1532
- <template v-else-if="raw.value == null">{{ raw.name }}</template>
1533
- <template v-else>
1534
- {{ raw.name }}: {{ formatTooltipValue(raw.value) }}
1535
- </template>
1536
- </slot>
1537
- </template>
1538
- </ChoroplethTooltip>
1539
- </div>
1609
+ <g ref="mapGroupRef">
1610
+ <g ref="baseGroupRef" />
1611
+ <g ref="overlayGroupRef" />
1612
+ </g>
1613
+ </svg>
1614
+ <button
1615
+ v-if="isZoomed"
1616
+ type="button"
1617
+ class="choropleth-reset"
1618
+ aria-label="Reset zoom"
1619
+ @click="resetZoom"
1620
+ >
1621
+ Reset
1622
+ </button>
1623
+ <ChoroplethTooltip v-if="hasInteractiveTooltip" ref="tooltipChildRef">
1624
+ <template #default="raw">
1625
+ <slot name="tooltip" v-bind="narrowSlotProps(raw)">
1626
+ <span v-if="tooltipFormat" v-html="tooltipFormat(raw)" />
1627
+ <template v-else-if="raw.value == null">{{ raw.name }}</template>
1628
+ <template v-else>
1629
+ {{ raw.name }}: {{ formatTooltipValue(raw.value) }}
1630
+ </template>
1631
+ </slot>
1632
+ </template>
1633
+ </ChoroplethTooltip>
1634
+ </div>
1635
+ </Teleport>
1540
1636
  </template>
1541
1637
 
1542
1638
  <style scoped>
@@ -1566,6 +1662,15 @@ watch(
1566
1662
  cursor: grab;
1567
1663
  }
1568
1664
 
1665
+ /* While expanded the wrapper is a flex column (inline style); the SVG keeps
1666
+ its prop-driven size otherwise, so stretch it to fill the expanded box. */
1667
+ .choropleth-wrapper.is-fullscreen svg {
1668
+ flex: 1 1 auto;
1669
+ min-height: 0;
1670
+ height: 100%;
1671
+ width: 100%;
1672
+ }
1673
+
1569
1674
  .choropleth-wrapper.pannable svg:active {
1570
1675
  cursor: grabbing;
1571
1676
  }
@@ -180,6 +180,68 @@ grows automatically for additional lines.
180
180
  </template>
181
181
  </ComponentDemo>
182
182
 
183
+ ### Font sizes
184
+
185
+ Every text element on the chart has its own style prop and default size (in px). The text-styling props (`axisLabelStyle`, `tickLabelStyle`, `legendStyle`) take a `LabelStyle` — `{ fontSize?, color?, fontWeight? }` — and `titleStyle` adds `lineHeight` and `align`. Unset fields fall back to the defaults below.
186
+
187
+ | Chart part | Prop / field | Type | Default size |
188
+ | ------------------------------------------- | ------------------------------------ | ------------ | -----------: |
189
+ | Title | `titleStyle` | `TitleStyle` | 14 |
190
+ | Axis labels (`xLabel` / `yLabel`) | `axisLabelStyle` | `LabelStyle` | 13 |
191
+ | Axis tick labels (the numbers on each axis) | `tickLabelStyle` | `LabelStyle` | 10 |
192
+ | Inline legend | `legendStyle` | `LabelStyle` | 11 |
193
+ | Area section label | `areaSection.inlineLabelStyle` | `LabelStyle` | 11 |
194
+ | Area section description | `areaSection.inlineDescriptionStyle` | `LabelStyle` | 11 |
195
+ | Annotation text | `annotation.fontSize` | `number` | 13 |
196
+
197
+ These are single styles applied to both axes — there's no separate x vs y font size. The same props (except the LineChart-only area-section fields) exist on `BarChart`, since both compose `ChartCommonProps`.
198
+
199
+ <ComponentDemo>
200
+ <LineChart
201
+ :series="[
202
+ { data: [0, 12, 28, 45, 60, 55, 40, 25, 12], color: '#0057b7', legend: 'No interventions' },
203
+ { data: [0, 8, 18, 30, 40, 35, 25, 14, 6], color: '#ef4444', legend: 'Masking + distancing' },
204
+ ]"
205
+ :height="240"
206
+ title="Daily ED visits"
207
+ x-label="Days"
208
+ y-label="Cases"
209
+ :title-style="{ fontSize: 18 }"
210
+ :axis-label-style="{ fontSize: 15, fontWeight: 700 }"
211
+ :tick-label-style="{ fontSize: 13 }"
212
+ :legend-style="{ fontSize: 14 }"
213
+ />
214
+
215
+ <template #code>
216
+
217
+ ```vue
218
+ <LineChart
219
+ :series="[
220
+ {
221
+ data: [0, 12, 28, 45, 60, 55, 40, 25, 12],
222
+ color: '#0057b7',
223
+ legend: 'No interventions',
224
+ },
225
+ {
226
+ data: [0, 8, 18, 30, 40, 35, 25, 14, 6],
227
+ color: '#ef4444',
228
+ legend: 'Masking + distancing',
229
+ },
230
+ ]"
231
+ :height="240"
232
+ title="Daily ED visits"
233
+ x-label="Days"
234
+ y-label="Cases"
235
+ :title-style="{ fontSize: 18 }"
236
+ :axis-label-style="{ fontSize: 15, fontWeight: 700 }"
237
+ :tick-label-style="{ fontSize: 13 }"
238
+ :legend-style="{ fontSize: 14 }"
239
+ />
240
+ ```
241
+
242
+ </template>
243
+ </ComponentDemo>
244
+
183
245
  ### Multiple series
184
246
 
185
247
  <ComponentDemo>
@@ -263,6 +325,8 @@ Set `legend` on a series to add an entry to the inline legend strip above the pl
263
325
 
264
326
  Hover over the chart to see a tooltip with values at each data point. Set `tooltip-trigger="hover"` to enable the built-in tooltip with crosshair and highlight dots. Use the `#tooltip` slot for custom content. Pass `tooltip-value-format` to control how numeric values render (e.g. percentages, currency); it falls back to `y-tick-format` when omitted.
265
327
 
328
+ On touch devices, drag **horizontally** across the chart to scrub the tooltip between points; a **vertical** swipe scrolls the page instead of getting captured by the chart.
329
+
266
330
  <ComponentDemo>
267
331
  <LineChart
268
332
  :series="[
@@ -415,6 +479,11 @@ Set `outline: true` on a series to draw a page-colored stroke behind the
415
479
  line. This is useful when lines overlap, cross dense areas, or sit on a
416
480
  busy background — the outline keeps each series visually distinct.
417
481
 
482
+ Override the outline's color with `outlineColor` (any CSS color; defaults
483
+ to the page background `var(--color-bg-0, #fff)`) and its thickness with
484
+ `outlineWidth` (extra width added to the line's `strokeWidth`, split evenly
485
+ per side; defaults to `4`).
486
+
418
487
  <ComponentDemo>
419
488
  <LineChart
420
489
  :series="[
@@ -621,7 +690,9 @@ inverts. The white `outline`, if any, is unaffected.
621
690
  Use the `areas` prop to fill a band between two y-series — useful for
622
691
  confidence intervals or min/max envelopes around a mean line. Each `Area`
623
692
  takes parallel `upper` and `lower` arrays (and an optional `x` array, just
624
- like `Series`).
693
+ like `Series`). Set `blendMode` on an `Area` to apply a CSS
694
+ `mix-blend-mode` to its fill so overlapping bands combine their colors
695
+ (e.g. `"multiply"`) instead of the later one obscuring the earlier.
625
696
 
626
697
  <ComponentDemo>
627
698
  <LineChart
@@ -928,10 +999,17 @@ positioned from the anchor as usual via `offset`.
928
999
 
929
1000
  ### Chart menu
930
1001
 
931
- The chart menu (top-right) leads with Expand / Collapse and then Save as SVG,
932
- Save as PNG, and Download CSV. Expand grows the chart to fill the browser
933
- window so the plot has more room; press <kbd>Esc</kbd> or pick "Collapse" to
934
- return to the inline size. Set `menu="false"` to hide the trigger entirely.
1002
+ The chart menu (top-right) leads with Fullscreen and then Save as SVG,
1003
+ Save as PNG, and Download CSV. Fullscreen grows the chart to fill the browser
1004
+ window so the plot has more room; while expanded the menu trigger is replaced
1005
+ by a close (✕) button click it or press <kbd>Esc</kbd> to return to the
1006
+ inline size. Set `menu="false"` to hide the trigger entirely.
1007
+
1008
+ While expanded the chart is teleported to `<body>` so it reliably covers the
1009
+ viewport even when an ancestor uses `transform`, `filter`, `contain`, or
1010
+ establishes a stacking context. If your app doesn't mount at the document root
1011
+ (e.g. inside a shadow root or a dedicated overlay container), point
1012
+ `fullscreenTarget` at a CSS selector or element to teleport there instead.
935
1013
 
936
1014
  ### Custom CSV download
937
1015
 
@@ -1008,6 +1086,7 @@ until the user clicks Download:
1008
1086
  | `filename` | `string` | No | — |
1009
1087
  | `downloadLink` | `boolean \| string` | No | — |
1010
1088
  | `downloadButton` | `boolean \| string` | No | — |
1089
+ | `fullscreenTarget` | `string \| HTMLElement` | No | — |
1011
1090
  | `annotations` | `readonly ChartAnnotation[]` | No | — |
1012
1091
  | `chartPadding` | `ChartPadding` | No | — |
1013
1092
  | `y` | `LineChartData` | No | — |
@@ -1070,6 +1149,8 @@ interface Series {
1070
1149
  opacity?: number;
1071
1150
  line?: boolean;
1072
1151
  outline?: boolean; // page-colored stroke drawn behind the line
1152
+ outlineColor?: string; // outline stroke color (default: var(--color-bg-0, #fff))
1153
+ outlineWidth?: number; // extra width added to strokeWidth (default: 4)
1073
1154
  dots?: boolean;
1074
1155
  dotRadius?: number;
1075
1156
  dotFill?: string;
@@ -1086,6 +1167,7 @@ interface Area {
1086
1167
  x?: LineChartData; // optional parallel x-values
1087
1168
  color?: string;
1088
1169
  opacity?: number;
1170
+ blendMode?: BlendMode; // CSS mix-blend-mode for the fill
1089
1171
  }
1090
1172
  ```
1091
1173