@cfasim-ui/docs 0.5.1 → 0.6.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.
@@ -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
  }
@@ -263,6 +263,8 @@ Set `legend` on a series to add an entry to the inline legend strip above the pl
263
263
 
264
264
  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
265
 
266
+ 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.
267
+
266
268
  <ComponentDemo>
267
269
  <LineChart
268
270
  :series="[
@@ -415,6 +417,11 @@ Set `outline: true` on a series to draw a page-colored stroke behind the
415
417
  line. This is useful when lines overlap, cross dense areas, or sit on a
416
418
  busy background — the outline keeps each series visually distinct.
417
419
 
420
+ Override the outline's color with `outlineColor` (any CSS color; defaults
421
+ to the page background `var(--color-bg-0, #fff)`) and its thickness with
422
+ `outlineWidth` (extra width added to the line's `strokeWidth`, split evenly
423
+ per side; defaults to `4`).
424
+
418
425
  <ComponentDemo>
419
426
  <LineChart
420
427
  :series="[
@@ -928,10 +935,17 @@ positioned from the anchor as usual via `offset`.
928
935
 
929
936
  ### Chart menu
930
937
 
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.
938
+ The chart menu (top-right) leads with Fullscreen and then Save as SVG,
939
+ Save as PNG, and Download CSV. Fullscreen grows the chart to fill the browser
940
+ window so the plot has more room; while expanded the menu trigger is replaced
941
+ by a close (✕) button click it or press <kbd>Esc</kbd> to return to the
942
+ inline size. Set `menu="false"` to hide the trigger entirely.
943
+
944
+ While expanded the chart is teleported to `<body>` so it reliably covers the
945
+ viewport even when an ancestor uses `transform`, `filter`, `contain`, or
946
+ establishes a stacking context. If your app doesn't mount at the document root
947
+ (e.g. inside a shadow root or a dedicated overlay container), point
948
+ `fullscreenTarget` at a CSS selector or element to teleport there instead.
935
949
 
936
950
  ### Custom CSV download
937
951
 
@@ -1008,6 +1022,7 @@ until the user clicks Download:
1008
1022
  | `filename` | `string` | No | — |
1009
1023
  | `downloadLink` | `boolean \| string` | No | — |
1010
1024
  | `downloadButton` | `boolean \| string` | No | — |
1025
+ | `fullscreenTarget` | `string \| HTMLElement` | No | — |
1011
1026
  | `annotations` | `readonly ChartAnnotation[]` | No | — |
1012
1027
  | `chartPadding` | `ChartPadding` | No | — |
1013
1028
  | `y` | `LineChartData` | No | — |
@@ -1070,6 +1085,8 @@ interface Series {
1070
1085
  opacity?: number;
1071
1086
  line?: boolean;
1072
1087
  outline?: boolean; // page-colored stroke drawn behind the line
1088
+ outlineColor?: string; // outline stroke color (default: var(--color-bg-0, #fff))
1089
+ outlineWidth?: number; // extra width added to strokeWidth (default: 4)
1073
1090
  dots?: boolean;
1074
1091
  dotRadius?: number;
1075
1092
  dotFill?: string;