railsui_charts 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3fa00b0555667e87a913c0341ac9efcff529d54d4eb3b7e6a70fbe12167d1a1
4
- data.tar.gz: 47b6cc37a82c21c1ccbc1213a8876fa37fa8a2573ef6663629d4e1d09d4cb7fc
3
+ metadata.gz: 5be8bf55b84a7b5a3c7294a1b3958d5042e793efec37266956e2767a74cf1df0
4
+ data.tar.gz: 891215bb98875af325556b150d2c6e37db60cff1d71f68e62e174efeb4c51ac7
5
5
  SHA512:
6
- metadata.gz: c5140c807d377faf06d02f1da3b1807d71bf023e3551ba74429ad5bc26aed89e8b19746570337b004080e881beced4cb76a7234c486bf1b28cd6b0810eed423c
7
- data.tar.gz: a38ce24f9ba774e6532df52c87e8c71c6260c2103e679702ecb85e50f06d37b40aa09a7d4b3efd1a6cf74eeaa199b48f00708cc76d90b6eaf1fb2a61789904c0
6
+ metadata.gz: 1c422dda350f4a4b6ca52119fff256715589fda00df0421560b1d6e33b6b4c17162b26ec52f9e82502de5e05e8c33a6f503d5f427af0766b098c2b04b1d0e2ae
7
+ data.tar.gz: e48dee54260ae743336a05c646a8061f090e7c0ccba4fb5c8d82ab497440afacb8fe5772b67afc00aad62dc4cfdc1031fa6d7beda944bb6b959607f13a763d27
data/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ public API may change between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.2]
10
+
11
+ ### Added
12
+
13
+ - `accessible_table:` on `railsui_chart`, for charts whose series are not the
14
+ numbers a reader wants. A caller passes `{ headers:, rows: }` and it replaces
15
+ the table derived from the series. The CSV export in Rails UI Charts Pro is
16
+ built from that table, so it is corrected by the same option. This is what
17
+ lets a waterfall describe itself as movement rather than as the invisible
18
+ plinth it stacks to get the shape drawn.
19
+
20
+ ### Fixed
21
+
22
+ - Sparkline charts now use the Rails UI tooltip renderer, so hovered points show
23
+ their label and formatted value instead of an empty tooltip.
24
+ - Range-bar and labelled-point tooltips now read the hovered point from the
25
+ chart config, so timelines and treemaps show the right title, value, and
26
+ distributed color.
27
+ - Range-bar points keep their `name` and `meta` payloads for tooltip consumers.
28
+ - Chart loading skeletons now follow the same treatment as metric cards: they
29
+ reserve the footprint, show lightweight skeleton bars, and leave the plot area
30
+ empty until real data arrives. They align left by default and accept
31
+ `align: :center` or `align: :right` when a layout needs it.
32
+
9
33
  ## [0.2.0]
10
34
 
11
35
  The JavaScript now comes from the gem instead of being copied into the
data/README.md CHANGED
@@ -510,12 +510,14 @@ A series of zeroes is **not** empty. A quiet day still has something to say, and
510
510
 
511
511
  ```erb
512
512
  <%= railsui_metric_card_skeleton chart_height: 180 %>
513
- <%= railsui_chart_skeleton height: 240, type: :donut %>
513
+ <%= railsui_chart_skeleton height: 240 %>
514
514
  ```
515
515
 
516
516
  Render one server-side and let a Turbo Stream swap in the real thing.
517
517
 
518
- The card skeleton stands in for the text that is coming and leaves the plot area empty a slab where the chart goes claims more about the shape of the data than a loading state can know. A standalone chart skeleton keeps faint gridlines, since it has nothing else to say it is a chart, and takes the shape of its `type:`.
518
+ The loading helpers stand in for the text and chrome that are coming and leave the plot area empty. A slab where the chart goes claims more about the shape of the data than a loading state can know.
519
+
520
+ Chart skeletons align left by default. Pass `align: :center` or `align: :right` when a specific layout needs it.
519
521
 
520
522
  On **refetch**, don't reach for the skeleton. Any element inside a container marked `aria-busy="true"` — which is what Turbo does to a frame while it loads — holds its previous render at reduced opacity instead. The numbers stay on screen and the layout stays still; a skeleton would throw the chart away and flash.
521
523
 
@@ -550,7 +552,7 @@ The Stimulus controller initializes charts on `connect` and destroys them on `di
550
552
 
551
553
  ## Full access
552
554
 
553
- A Rails UI membership adds `railsui_charts_pro`, which builds on this gem
555
+ A Rails UI subscription adds `railsui_charts_pro`, which builds on this gem
554
556
  rather than replacing it:
555
557
 
556
558
  - **Time series** — `TimeSeries` fills the gaps a `GROUP BY` leaves behind, buckets to any interval, and sums rows that collapse together
@@ -88,7 +88,7 @@ var railsui_chart_controller_default = class extends Controller {
88
88
  // theme, and it leads with what changed rather than with the date.
89
89
  applyTooltip(options) {
90
90
  if (options.tooltip_style === false) return options;
91
- if (options.tooltip?.custom || options.chart?.sparkline?.enabled) return options;
91
+ if (options.tooltip?.custom) return options;
92
92
  const format = this.formatterFor(options.format || "number", options.currency);
93
93
  const rowFormats = (options.series_formats || []).map((name) => this.formatterFor(name, options.currency));
94
94
  const comparedDates = options.compare_categories || [];
@@ -101,6 +101,13 @@ var railsui_chart_controller_default = class extends Controller {
101
101
  ...options.tooltip || {},
102
102
  custom: ({ series, seriesIndex, dataPointIndex, w }) => {
103
103
  const labels = w.globals.categoryLabels?.length ? w.globals.categoryLabels : w.globals.labels;
104
+ if (options.chart?.type === "rangeBar") {
105
+ return this.rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format);
106
+ }
107
+ const configuredPoint = this.configuredPoint(options, w, seriesIndex, dataPointIndex);
108
+ if (this.labelledPoint(configuredPoint)) {
109
+ return this.labelledPointTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format);
110
+ }
104
111
  if (!Array.isArray(series[0])) {
105
112
  const slice = seriesIndex ?? dataPointIndex;
106
113
  const row = { label: "", value: series[slice], color: w.globals.colors[slice], format: rowFormats[slice] || null };
@@ -124,6 +131,125 @@ var railsui_chart_controller_default = class extends Controller {
124
131
  }
125
132
  };
126
133
  }
134
+ configuredPoint(options, w, seriesIndex, dataPointIndex) {
135
+ return (w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}).data?.[dataPointIndex];
136
+ }
137
+ labelledPoint(point) {
138
+ return point !== null && typeof point === "object" && !Array.isArray(point) && "x" in point && "y" in point;
139
+ }
140
+ labelledPointTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
141
+ const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {};
142
+ const point = this.configuredPoint(options, w, seriesIndex, dataPointIndex) || {};
143
+ const title = point.name || point.label || point.x || configuredSeries.name;
144
+ if (Array.isArray(point.y)) {
145
+ return this.arrayPointTooltipMarkup(point, configuredSeries, options, w, seriesIndex, dataPointIndex, format);
146
+ }
147
+ const row = {
148
+ label: configuredSeries.name || "Value",
149
+ value: point.y ?? series?.[seriesIndex]?.[dataPointIndex],
150
+ color: this.pointColor(options, w, seriesIndex, dataPointIndex),
151
+ format: null
152
+ };
153
+ return this.tooltipMarkup(title, [row], null, format);
154
+ }
155
+ arrayPointTooltipMarkup(point, configuredSeries, options, w, seriesIndex, dataPointIndex, format) {
156
+ const labels = options.tooltip_value_labels || this.defaultArrayValueLabels(options.chart?.type, point.y.length);
157
+ const color = this.pointColor(options, w, seriesIndex, dataPointIndex);
158
+ const rows = point.y.map((value, index) => ({
159
+ label: labels[index] || `Value ${index + 1}`,
160
+ value,
161
+ color: options.tooltip_value_colors?.[index] || color,
162
+ format: null
163
+ }));
164
+ if (point.v !== void 0 && point.v !== null) {
165
+ rows.push({
166
+ label: options.tooltip_volume_label || "Volume",
167
+ value: point.v,
168
+ color: options.tooltip_volume_color || color,
169
+ format: this.formatterFor(options.volume_format || "human", options.currency)
170
+ });
171
+ }
172
+ return this.tooltipMarkup(point.name || point.label || point.x || configuredSeries.name, rows, null, format);
173
+ }
174
+ defaultArrayValueLabels(type, length) {
175
+ if (type === "candlestick" && length === 4) return ["Open", "High", "Low", "Close"];
176
+ if (type === "boxPlot" && length === 5) return ["Min", "Q1", "Median", "Q3", "Max"];
177
+ if (type === "rangeArea" && length === 2) return ["Low", "High"];
178
+ return [];
179
+ }
180
+ rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
181
+ const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {};
182
+ const point = configuredSeries.data?.[dataPointIndex] || {};
183
+ const fallbackValue = series?.[seriesIndex]?.[dataPointIndex];
184
+ const value = Array.isArray(point.y) ? point.y : fallbackValue;
185
+ const title = point.name || point.label || point.meta?.label || point.x || w.globals.categoryLabels?.[dataPointIndex] || w.globals.labels?.[dataPointIndex] || configuredSeries.name;
186
+ const rowLabel = point.name || point.label || point.meta?.label ? point.x || configuredSeries.name || "Range" : "Range";
187
+ const row = {
188
+ label: rowLabel,
189
+ value: this.formatRangeValue(value, options, format),
190
+ color: this.pointColor(options, w, seriesIndex, dataPointIndex),
191
+ format: null
192
+ };
193
+ return this.tooltipMarkup(title, [row], null, null);
194
+ }
195
+ pointColor(options, w, seriesIndex, dataPointIndex) {
196
+ if (options.plotOptions?.bar?.distributed && Array.isArray(options.colors)) {
197
+ return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex];
198
+ }
199
+ if (options.plotOptions?.treemap?.distributed && Array.isArray(options.colors)) {
200
+ return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex];
201
+ }
202
+ return w.globals.colors?.[seriesIndex] || options.colors?.[seriesIndex] || options.colors?.[0];
203
+ }
204
+ formatRangeValue(value, options, format) {
205
+ const values = Array.isArray(value) ? value : [value];
206
+ const present = values.filter((entry) => entry !== null && entry !== void 0);
207
+ if (present.length === 0) return null;
208
+ if (present.length === 2 && present.every((entry) => this.timestampLike(entry))) {
209
+ const [start, finish] = present;
210
+ if (this.sameTimestampDay(start, finish, options)) {
211
+ const startTime = this.formatTimestampTime(start, options);
212
+ const finishTime = this.formatTimestampTime(finish, options);
213
+ return startTime === finishTime ? `${this.formatTimestampDate(start, options)}, ${startTime}` : `${this.formatTimestampDate(start, options)}, ${startTime} - ${finishTime}`;
214
+ }
215
+ }
216
+ const formatted = present.map((entry) => this.formatRangeEndpoint(entry, options, format));
217
+ if (formatted.length === 1 || formatted[0] === formatted[1]) return formatted[0];
218
+ return `${formatted[0]} - ${formatted[formatted.length - 1]}`;
219
+ }
220
+ formatRangeEndpoint(value, options, format) {
221
+ if (this.timestampLike(value)) return `${this.formatTimestampDate(value, options)}, ${this.formatTimestampTime(value, options)}`;
222
+ if (format) return format(value);
223
+ return value;
224
+ }
225
+ timestampLike(value) {
226
+ return typeof value === "number" && Number.isFinite(value) && Math.abs(value) >= 1e11;
227
+ }
228
+ sameTimestampDay(left, right, options) {
229
+ const leftDate = new Date(left);
230
+ const rightDate = new Date(right);
231
+ if (this.datetimeUTC(options)) {
232
+ return leftDate.toISOString().slice(0, 10) === rightDate.toISOString().slice(0, 10);
233
+ }
234
+ return leftDate.getFullYear() === rightDate.getFullYear() && leftDate.getMonth() === rightDate.getMonth() && leftDate.getDate() === rightDate.getDate();
235
+ }
236
+ formatTimestampDate(value, options) {
237
+ return new Intl.DateTimeFormat("en-US", {
238
+ ...this.datetimeUTC(options) ? { timeZone: "UTC" } : {},
239
+ month: "short",
240
+ day: "numeric"
241
+ }).format(new Date(value));
242
+ }
243
+ formatTimestampTime(value, options) {
244
+ return new Intl.DateTimeFormat("en-US", {
245
+ ...this.datetimeUTC(options) ? { timeZone: "UTC" } : {},
246
+ hour: "numeric",
247
+ minute: "2-digit"
248
+ }).format(new Date(value));
249
+ }
250
+ datetimeUTC(options) {
251
+ return options.xaxis?.labels?.datetimeUTC !== false;
252
+ }
127
253
  tooltipDelta(current, previous, upIsGood) {
128
254
  if (![current, previous].every((n) => typeof n === "number") || previous === 0) return null;
129
255
  const change = (current - previous) / Math.abs(previous) * 100;
@@ -350,18 +476,49 @@ import { Controller as Controller3 } from "@hotwired/stimulus";
350
476
  var railsui_metric_dialog_controller_default = class extends Controller3 {
351
477
  static targets = ["dialog"];
352
478
  open() {
479
+ this.dialogTarget.classList.remove("railsui-metric-dialog--closing");
353
480
  this.dialogTarget.showModal();
354
- this.dialogTarget.querySelectorAll(".railsui-chart").forEach((chart) => {
355
- chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"));
481
+ requestAnimationFrame(() => {
482
+ this.dialogTarget.classList.add("railsui-metric-dialog--open");
483
+ this.refreshCharts();
356
484
  });
357
485
  }
358
486
  close() {
359
- this.dialogTarget.close();
487
+ this.closeDialog();
488
+ }
489
+ cancel(event) {
490
+ event.preventDefault();
491
+ this.closeDialog();
360
492
  }
361
493
  // Clicking the backdrop lands on the dialog element itself; a click anywhere
362
494
  // inside lands on a child.
363
495
  closeOnBackdrop(event) {
364
- if (event.target === this.dialogTarget) this.dialogTarget.close();
496
+ if (event.target === this.dialogTarget) this.closeDialog();
497
+ }
498
+ refreshCharts() {
499
+ this.dialogTarget.querySelectorAll(".railsui-chart").forEach((chart) => {
500
+ chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"));
501
+ });
502
+ }
503
+ closeDialog() {
504
+ const dialog = this.dialogTarget;
505
+ if (!dialog.open) return;
506
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
507
+ dialog.classList.remove("railsui-metric-dialog--open", "railsui-metric-dialog--closing");
508
+ dialog.close();
509
+ return;
510
+ }
511
+ dialog.classList.remove("railsui-metric-dialog--open");
512
+ dialog.classList.add("railsui-metric-dialog--closing");
513
+ let closed = false;
514
+ const finish = () => {
515
+ if (closed) return;
516
+ closed = true;
517
+ dialog.classList.remove("railsui-metric-dialog--closing");
518
+ dialog.close();
519
+ };
520
+ dialog.addEventListener("transitionend", finish, { once: true });
521
+ setTimeout(finish, 240);
365
522
  }
366
523
  };
367
524
 
@@ -171,12 +171,22 @@
171
171
  }
172
172
 
173
173
  .railsui-chart-state--loading {
174
+ align-items: center;
175
+ justify-content: flex-start;
174
176
  border-style: none;
175
- padding: 0;
177
+ padding: 1rem;
178
+ text-align: left;
179
+ }
180
+
181
+ .railsui-chart-state--loading-center {
182
+ justify-content: center;
183
+ }
184
+
185
+ .railsui-chart-state--loading-right {
186
+ justify-content: flex-end;
176
187
  }
177
188
 
178
189
  /* Shared placeholder surface: a fill plus the sheen that crosses it. */
179
- .railsui-chart-skeleton,
180
190
  .railsui-skeleton-bar {
181
191
  position: relative;
182
192
  overflow: hidden;
@@ -196,30 +206,33 @@
196
206
  .railsui-skeleton-bar--meta { width: 7rem; height: 0.75rem; }
197
207
  .railsui-skeleton-bar--footer { width: 6.5rem; height: 0.75rem; }
198
208
 
199
- /* Gridlines rather than a plain block, so a standalone chart placeholder still
200
- reads as a chart arriving. */
209
+ /* Same rule as the metric card skeleton: stand in for surrounding text and
210
+ controls, but leave the plot area empty. A filled chart slab claims too much
211
+ about data that has not arrived. */
201
212
  .railsui-chart-skeleton {
213
+ display: flex;
214
+ flex-direction: column;
215
+ width: clamp(18rem, 48%, 42rem);
216
+ max-width: 100%;
217
+ gap: 0.5rem;
218
+ }
219
+
220
+ .railsui-chart-skeleton .railsui-skeleton-bar--label { width: 36%; }
221
+ .railsui-chart-skeleton .railsui-skeleton-bar--value { width: 76%; }
222
+ .railsui-chart-skeleton .railsui-skeleton-bar--meta { width: 54%; }
223
+ .railsui-chart-skeleton .railsui-skeleton-bar--footer { width: 48%; }
224
+
225
+ .railsui-chart-skeleton__plot {
202
226
  display: block;
203
- width: 100%;
204
- min-height: inherit;
205
- align-self: stretch;
206
- border-radius: 0.5rem;
207
- /* Its own line colour, softer than a real gridline. A placeholder should
208
- suggest the shape of what is coming without competing with the charts
209
- already on screen around it. */
210
- background-image: repeating-linear-gradient(
211
- to bottom,
212
- var(--rui-chart-skeleton-line) 0,
213
- var(--rui-chart-skeleton-line) 1px,
214
- transparent 1px,
215
- transparent 25%
216
- );
227
+ min-height: 4rem;
228
+ }
229
+
230
+ .railsui-chart-skeleton .railsui-skeleton-bar--footer {
231
+ margin-top: 0.25rem;
217
232
  }
218
233
 
219
- /* A sheen sweeping across, rather than the whole block breathing. Pulsing
220
- opacity dims the gridlines too and reads as a broken element; a sweep reads
221
- as work in progress. */
222
- .railsui-chart-skeleton::after,
234
+ /* A sheen sweeping across, rather than the whole block breathing. Pulsing reads
235
+ as a broken element; a sweep reads as work in progress. */
223
236
  .railsui-skeleton-bar::after {
224
237
  content: "";
225
238
  position: absolute;
@@ -234,16 +247,6 @@
234
247
  animation: railsui-chart-sweep 1.6s ease-in-out infinite;
235
248
  }
236
249
 
237
- /* Circular forms get a disc, so the placeholder is the shape of the thing
238
- being waited on. */
239
- .railsui-chart-skeleton--circular {
240
- width: auto;
241
- aspect-ratio: 1;
242
- margin: 0 auto;
243
- border-radius: 9999px;
244
- background-image: none;
245
- }
246
-
247
250
  @keyframes railsui-chart-sweep {
248
251
  100% { transform: translateX(100%); }
249
252
  }
@@ -259,7 +262,6 @@
259
262
 
260
263
  @media (prefers-reduced-motion: reduce) {
261
264
  /* The skeleton still reads as a placeholder standing still. */
262
- .railsui-chart-skeleton::after,
263
265
  .railsui-skeleton-bar::after {
264
266
  animation: none;
265
267
  opacity: 0.4;
@@ -516,6 +518,12 @@
516
518
  margin-top: 0.5rem;
517
519
  }
518
520
 
521
+ .railsui-metric-card__chart .railsui-chart,
522
+ .railsui-metric-card__chart .apexcharts-canvas,
523
+ .railsui-metric-card__chart .apexcharts-svg {
524
+ overflow: visible !important;
525
+ }
526
+
519
527
  /* Rendered outside the plot so the first and last labels sit flush with the
520
528
  card edges. Apex centres them on their data point and clips at the canvas
521
529
  edge, which costs either the label's first characters or plot width. */
@@ -566,22 +574,41 @@
566
574
  /* --------------------------------------------------------- expanded view */
567
575
 
568
576
  .railsui-metric-dialog {
569
- width: min(56rem, calc(100vw - 2rem));
570
- max-height: calc(100vh - 4rem);
577
+ width: min(64rem, calc(100vw - 2rem));
578
+ max-height: calc(100vh - 2rem);
571
579
  padding: 0;
572
580
  border: 1px solid var(--rui-chart-border);
573
581
  border-radius: 0.75rem;
574
582
  background-color: var(--rui-chart-surface);
575
583
  color: var(--rui-chart-metric-value);
576
- overflow: hidden;
584
+ opacity: 0;
585
+ overflow: auto;
586
+ transform: translateY(0.5rem) scale(0.98);
587
+ transition: opacity 160ms ease, transform 180ms ease;
577
588
  }
578
589
 
579
590
  .railsui-metric-dialog::backdrop {
580
- background-color: rgba(15, 23, 42, 0.5);
591
+ background-color: transparent;
592
+ transition: background-color 160ms ease;
593
+ }
594
+
595
+ .railsui-metric-dialog--open {
596
+ opacity: 1;
597
+ transform: translateY(0) scale(1);
598
+ }
599
+
600
+ .railsui-metric-dialog--open::backdrop {
601
+ background-color: rgba(15, 23, 42, 0.48);
602
+ }
603
+
604
+ .railsui-metric-dialog--closing {
605
+ opacity: 0;
606
+ transform: translateY(0.25rem) scale(0.99);
581
607
  }
582
608
 
583
609
  .railsui-metric-dialog__panel {
584
- padding: 1.25rem 1.5rem 1.5rem;
610
+ min-width: 0;
611
+ padding: 1.25rem 1.75rem 1.5rem;
585
612
  }
586
613
 
587
614
  .railsui-metric-dialog__head {
@@ -593,21 +620,67 @@
593
620
  }
594
621
 
595
622
  .railsui-metric-dialog__close {
623
+ display: inline-flex;
624
+ align-items: center;
625
+ justify-content: center;
596
626
  flex-shrink: 0;
597
- width: 1.75rem;
598
- height: 1.75rem;
627
+ width: 2.5rem;
628
+ height: 2.5rem;
599
629
  border: 0;
600
- border-radius: 9999px;
630
+ border-radius: 0.375rem;
601
631
  background-color: transparent;
602
632
  color: var(--rui-chart-metric-muted);
603
- font-size: 1.25rem;
604
- line-height: 1;
605
633
  cursor: pointer;
606
- transition: background-color 120ms ease;
634
+ transition: background-color 120ms ease, color 120ms ease;
607
635
  }
608
636
 
609
- .railsui-metric-dialog__close:hover {
637
+ .railsui-metric-dialog__close:hover,
638
+ .railsui-metric-dialog__close:focus-visible {
610
639
  background-color: var(--rui-chart-hover);
640
+ color: var(--rui-chart-metric-value);
641
+ }
642
+
643
+ .railsui-metric-dialog__close:focus-visible {
644
+ outline: 2px solid var(--rui-chart-primary);
645
+ outline-offset: 2px;
646
+ }
647
+
648
+ .railsui-metric-dialog__close-icon {
649
+ width: 1.5rem;
650
+ height: 1.5rem;
651
+ pointer-events: none;
652
+ }
653
+
654
+ .railsui-metric-dialog__chart,
655
+ .railsui-metric-dialog__chart .railsui-chart,
656
+ .railsui-metric-dialog__chart .apexcharts-canvas,
657
+ .railsui-metric-dialog__chart .apexcharts-svg {
658
+ overflow: visible !important;
659
+ }
660
+
661
+ @media (max-width: 640px) {
662
+ .railsui-metric-dialog {
663
+ width: calc(100vw - 1rem);
664
+ max-height: calc(100dvh - 1rem);
665
+ border-radius: 0.625rem;
666
+ }
667
+
668
+ .railsui-metric-dialog__panel {
669
+ padding: 1rem;
670
+ }
671
+
672
+ .railsui-metric-dialog__head {
673
+ gap: 0.75rem;
674
+ margin-bottom: 0.75rem;
675
+ }
676
+ }
677
+
678
+ @media (prefers-reduced-motion: reduce) {
679
+ .railsui-metric-dialog,
680
+ .railsui-metric-dialog::backdrop,
681
+ .railsui-metric-dialog__close {
682
+ transition: none;
683
+ }
611
684
  }
612
685
 
613
686
  /* ---------------------------------------------------------------- deltas */
@@ -113,7 +113,7 @@ export default class extends Controller {
113
113
  // `tooltip_style: false` hands the tooltip back to Apex; passing your own
114
114
  // `tooltip.custom` also wins.
115
115
  if (options.tooltip_style === false) return options
116
- if (options.tooltip?.custom || options.chart?.sparkline?.enabled) return options
116
+ if (options.tooltip?.custom) return options
117
117
 
118
118
  const format = this.formatterFor(options.format || "number", options.currency)
119
119
  // One formatter per series on a combo, so a currency row and a percentage
@@ -131,6 +131,16 @@ export default class extends Controller {
131
131
  custom: ({ series, seriesIndex, dataPointIndex, w }) => {
132
132
  const labels = w.globals.categoryLabels?.length ? w.globals.categoryLabels : w.globals.labels
133
133
 
134
+ if (options.chart?.type === "rangeBar") {
135
+ return this.rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format)
136
+ }
137
+
138
+ const configuredPoint = this.configuredPoint(options, w, seriesIndex, dataPointIndex)
139
+
140
+ if (this.labelledPoint(configuredPoint)) {
141
+ return this.labelledPointTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format)
142
+ }
143
+
134
144
  // A pie, donut or polar area hands back one number per slice rather
135
145
  // than one array per series, and names the hovered slice with
136
146
  // seriesIndex — dataPointIndex means nothing there. Read as a
@@ -171,6 +181,161 @@ export default class extends Controller {
171
181
  }
172
182
  }
173
183
 
184
+ configuredPoint(options, w, seriesIndex, dataPointIndex) {
185
+ return (w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}).data?.[dataPointIndex]
186
+ }
187
+
188
+ labelledPoint(point) {
189
+ return point !== null && typeof point === "object" && !Array.isArray(point) && "x" in point && "y" in point
190
+ }
191
+
192
+ labelledPointTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
193
+ const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}
194
+ const point = this.configuredPoint(options, w, seriesIndex, dataPointIndex) || {}
195
+ const title = point.name || point.label || point.x || configuredSeries.name
196
+ if (Array.isArray(point.y)) {
197
+ return this.arrayPointTooltipMarkup(point, configuredSeries, options, w, seriesIndex, dataPointIndex, format)
198
+ }
199
+
200
+ const row = {
201
+ label: configuredSeries.name || "Value",
202
+ value: point.y ?? series?.[seriesIndex]?.[dataPointIndex],
203
+ color: this.pointColor(options, w, seriesIndex, dataPointIndex),
204
+ format: null
205
+ }
206
+
207
+ return this.tooltipMarkup(title, [row], null, format)
208
+ }
209
+
210
+ arrayPointTooltipMarkup(point, configuredSeries, options, w, seriesIndex, dataPointIndex, format) {
211
+ const labels = options.tooltip_value_labels || this.defaultArrayValueLabels(options.chart?.type, point.y.length)
212
+ const color = this.pointColor(options, w, seriesIndex, dataPointIndex)
213
+ const rows = point.y.map((value, index) => ({
214
+ label: labels[index] || `Value ${index + 1}`,
215
+ value,
216
+ color: options.tooltip_value_colors?.[index] || color,
217
+ format: null
218
+ }))
219
+
220
+ if (point.v !== undefined && point.v !== null) {
221
+ rows.push({
222
+ label: options.tooltip_volume_label || "Volume",
223
+ value: point.v,
224
+ color: options.tooltip_volume_color || color,
225
+ format: this.formatterFor(options.volume_format || "human", options.currency)
226
+ })
227
+ }
228
+
229
+ return this.tooltipMarkup(point.name || point.label || point.x || configuredSeries.name, rows, null, format)
230
+ }
231
+
232
+ defaultArrayValueLabels(type, length) {
233
+ if (type === "candlestick" && length === 4) return ["Open", "High", "Low", "Close"]
234
+ if (type === "boxPlot" && length === 5) return ["Min", "Q1", "Median", "Q3", "Max"]
235
+ if (type === "rangeArea" && length === 2) return ["Low", "High"]
236
+
237
+ return []
238
+ }
239
+
240
+ rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
241
+ const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}
242
+ const point = configuredSeries.data?.[dataPointIndex] || {}
243
+ const fallbackValue = series?.[seriesIndex]?.[dataPointIndex]
244
+ const value = Array.isArray(point.y) ? point.y : fallbackValue
245
+ const title = point.name || point.label || point.meta?.label || point.x || w.globals.categoryLabels?.[dataPointIndex] || w.globals.labels?.[dataPointIndex] || configuredSeries.name
246
+ const rowLabel = point.name || point.label || point.meta?.label ? (point.x || configuredSeries.name || "Range") : "Range"
247
+
248
+ const row = {
249
+ label: rowLabel,
250
+ value: this.formatRangeValue(value, options, format),
251
+ color: this.pointColor(options, w, seriesIndex, dataPointIndex),
252
+ format: null
253
+ }
254
+
255
+ return this.tooltipMarkup(title, [row], null, null)
256
+ }
257
+
258
+ pointColor(options, w, seriesIndex, dataPointIndex) {
259
+ if (options.plotOptions?.bar?.distributed && Array.isArray(options.colors)) {
260
+ return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex]
261
+ }
262
+
263
+ if (options.plotOptions?.treemap?.distributed && Array.isArray(options.colors)) {
264
+ return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex]
265
+ }
266
+
267
+ return w.globals.colors?.[seriesIndex] || options.colors?.[seriesIndex] || options.colors?.[0]
268
+ }
269
+
270
+ formatRangeValue(value, options, format) {
271
+ const values = Array.isArray(value) ? value : [value]
272
+ const present = values.filter((entry) => entry !== null && entry !== undefined)
273
+
274
+ if (present.length === 0) return null
275
+
276
+ if (present.length === 2 && present.every((entry) => this.timestampLike(entry))) {
277
+ const [start, finish] = present
278
+
279
+ if (this.sameTimestampDay(start, finish, options)) {
280
+ const startTime = this.formatTimestampTime(start, options)
281
+ const finishTime = this.formatTimestampTime(finish, options)
282
+
283
+ return startTime === finishTime
284
+ ? `${this.formatTimestampDate(start, options)}, ${startTime}`
285
+ : `${this.formatTimestampDate(start, options)}, ${startTime} - ${finishTime}`
286
+ }
287
+ }
288
+
289
+ const formatted = present.map((entry) => this.formatRangeEndpoint(entry, options, format))
290
+ if (formatted.length === 1 || formatted[0] === formatted[1]) return formatted[0]
291
+
292
+ return `${formatted[0]} - ${formatted[formatted.length - 1]}`
293
+ }
294
+
295
+ formatRangeEndpoint(value, options, format) {
296
+ if (this.timestampLike(value)) return `${this.formatTimestampDate(value, options)}, ${this.formatTimestampTime(value, options)}`
297
+ if (format) return format(value)
298
+
299
+ return value
300
+ }
301
+
302
+ timestampLike(value) {
303
+ return typeof value === "number" && Number.isFinite(value) && Math.abs(value) >= 100_000_000_000
304
+ }
305
+
306
+ sameTimestampDay(left, right, options) {
307
+ const leftDate = new Date(left)
308
+ const rightDate = new Date(right)
309
+
310
+ if (this.datetimeUTC(options)) {
311
+ return leftDate.toISOString().slice(0, 10) === rightDate.toISOString().slice(0, 10)
312
+ }
313
+
314
+ return leftDate.getFullYear() === rightDate.getFullYear()
315
+ && leftDate.getMonth() === rightDate.getMonth()
316
+ && leftDate.getDate() === rightDate.getDate()
317
+ }
318
+
319
+ formatTimestampDate(value, options) {
320
+ return new Intl.DateTimeFormat("en-US", {
321
+ ...(this.datetimeUTC(options) ? { timeZone: "UTC" } : {}),
322
+ month: "short",
323
+ day: "numeric"
324
+ }).format(new Date(value))
325
+ }
326
+
327
+ formatTimestampTime(value, options) {
328
+ return new Intl.DateTimeFormat("en-US", {
329
+ ...(this.datetimeUTC(options) ? { timeZone: "UTC" } : {}),
330
+ hour: "numeric",
331
+ minute: "2-digit"
332
+ }).format(new Date(value))
333
+ }
334
+
335
+ datetimeUTC(options) {
336
+ return options.xaxis?.labels?.datetimeUTC !== false
337
+ }
338
+
174
339
  tooltipDelta(current, previous, upIsGood) {
175
340
  if (![current, previous].every((n) => typeof n === "number") || previous === 0) return null
176
341
 
@@ -6,22 +6,61 @@ export default class extends Controller {
6
6
  static targets = ["dialog"]
7
7
 
8
8
  open() {
9
+ this.dialogTarget.classList.remove("railsui-metric-dialog--closing")
9
10
  this.dialogTarget.showModal()
10
11
 
11
- // The chart inside was laid out while the dialog was closed, which means
12
- // it measured zero and drew nothing. Now that it has a size, tell it.
13
- this.dialogTarget.querySelectorAll(".railsui-chart").forEach((chart) => {
14
- chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"))
12
+ requestAnimationFrame(() => {
13
+ this.dialogTarget.classList.add("railsui-metric-dialog--open")
14
+ this.refreshCharts()
15
15
  })
16
16
  }
17
17
 
18
18
  close() {
19
- this.dialogTarget.close()
19
+ this.closeDialog()
20
+ }
21
+
22
+ cancel(event) {
23
+ event.preventDefault()
24
+ this.closeDialog()
20
25
  }
21
26
 
22
27
  // Clicking the backdrop lands on the dialog element itself; a click anywhere
23
28
  // inside lands on a child.
24
29
  closeOnBackdrop(event) {
25
- if (event.target === this.dialogTarget) this.dialogTarget.close()
30
+ if (event.target === this.dialogTarget) this.closeDialog()
31
+ }
32
+
33
+ refreshCharts() {
34
+ // The chart inside was laid out while the dialog was closed, which means
35
+ // it measured zero and drew nothing. Now that it has a size, tell it.
36
+ this.dialogTarget.querySelectorAll(".railsui-chart").forEach((chart) => {
37
+ chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"))
38
+ })
39
+ }
40
+
41
+ closeDialog() {
42
+ const dialog = this.dialogTarget
43
+ if (!dialog.open) return
44
+
45
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
46
+ dialog.classList.remove("railsui-metric-dialog--open", "railsui-metric-dialog--closing")
47
+ dialog.close()
48
+ return
49
+ }
50
+
51
+ dialog.classList.remove("railsui-metric-dialog--open")
52
+ dialog.classList.add("railsui-metric-dialog--closing")
53
+
54
+ let closed = false
55
+ const finish = () => {
56
+ if (closed) return
57
+
58
+ closed = true
59
+ dialog.classList.remove("railsui-metric-dialog--closing")
60
+ dialog.close()
61
+ }
62
+
63
+ dialog.addEventListener("transitionend", finish, { once: true })
64
+ setTimeout(finish, 240)
26
65
  }
27
66
  }
@@ -132,6 +132,12 @@ module RailsuiCharts
132
132
  @series.any? { |series| series[:type].present? }
133
133
  end
134
134
 
135
+ def complex_combo?
136
+ combo? && @series.each_with_index.any? do |series, index|
137
+ RailsuiCharts.config.complex_point_types.include?(series_point_type(series, index))
138
+ end
139
+ end
140
+
135
141
  def dual_axis?
136
142
  @series.any? { |series| series[:axis] == :right }
137
143
  end
@@ -152,7 +158,13 @@ module RailsuiCharts
152
158
  case point
153
159
  when Hash
154
160
  point = point.symbolize_keys if point.respond_to?(:symbolize_keys)
155
- { x: point[:x], y: span_or_value(point), z: point[:z] }
161
+ normalized = { x: point[:x], y: span_or_value(point), z: point[:z] }
162
+ normalized[:meta] = point[:meta] if point.key?(:meta)
163
+ normalized[:name] = point[:name] if point.key?(:name)
164
+ normalized[:label] = point[:label] if point.key?(:label)
165
+ normalized[:v] = point[:v] if point.key?(:v)
166
+ normalized[:points] = point[:points] if point.key?(:points)
167
+ normalized
156
168
  when Array
157
169
  { x: point[0], y: point[1], z: point[2] }
158
170
  else
@@ -171,9 +183,49 @@ module RailsuiCharts
171
183
  end
172
184
 
173
185
  def categories
186
+ return category_union if misaligned_series?
187
+
174
188
  @data.map { |d| d[:x] }.compact
175
189
  end
176
190
 
191
+ # The union of every series' labels, in the order they first appear.
192
+ #
193
+ # Categories used to be read off the first series alone, and every later
194
+ # series was then flattened to bare values and drawn from position zero.
195
+ # For series that share an x-axis — the ordinary case — that is the same
196
+ # thing. For series that do not, it silently plotted them in the wrong
197
+ # place: a projection labelled November landed on January, on top of the
198
+ # history it was meant to continue.
199
+ def category_union
200
+ # A caller who states the axis gets it. Series order decides first
201
+ # appearance otherwise, and that is not always the reading order: a
202
+ # forecast draws its band first so it sits behind the lines, which would
203
+ # otherwise put next November before last January.
204
+ @category_union ||= Array(@options[:categories]).presence ||
205
+ @series.flat_map { |series| series_labels(series) }.uniq
206
+ end
207
+
208
+ # Only when the series genuinely disagree. A series of bare values carries
209
+ # no labels to align on, and aligning identical label lists would be the
210
+ # same list — so in both cases nothing changes.
211
+ def misaligned_series?
212
+ return @misaligned unless @misaligned.nil?
213
+
214
+ @misaligned =
215
+ if @series.length < 2
216
+ false
217
+ elsif @options[:categories].present?
218
+ @series.all? { |series| series_labels(series).any? }
219
+ else
220
+ labels = @series.map { |series| series_labels(series) }
221
+ labels.none?(&:empty?) && labels.uniq.length > 1
222
+ end
223
+ end
224
+
225
+ def series_labels(series)
226
+ Array(series[:data]).filter_map { |point| point[:x] if point.is_a?(Hash) }
227
+ end
228
+
177
229
  # A bare array of values carries no labels, and Apex sizes the x-axis from
178
230
  # this list: given an empty one it draws nothing at all — no canvas, no
179
231
  # error, just an element that stays empty. Positions stand in, which is
@@ -534,35 +586,66 @@ module RailsuiCharts
534
586
  return series_values if circular?
535
587
 
536
588
  plotted = @series.each_with_index.map do |series, index|
537
- entry = { name: series[:name] || (index.zero? ? series_label : "Series #{index + 1}"), data: points_for(series[:data]) }
589
+ point_type = series_point_type(series, index)
590
+ entry = { name: series[:name] || (index.zero? ? series_label : "Series #{index + 1}"), data: points_for(series[:data], point_type) }
538
591
  # Only on a combo. Naming a type on every series of an ordinary chart
539
592
  # would override the chart-level one and quietly ignore `type:`.
540
- entry[:type] = series_type(series, index) if combo?
593
+ entry[:type] = apex_type_for(point_type) if combo?
541
594
  entry
542
595
  end
543
596
 
544
597
  comparing? ? plotted + [{ name: compare_label, data: @compare.map { |point| point[:y] } }] : plotted
545
598
  end
546
599
 
547
- def points_for(points)
548
- case @type
600
+ def series_point_type(series, index)
601
+ (series[:type] || (index.zero? ? @type : @series.first[:type]) || @type).to_sym
602
+ end
603
+
604
+ def points_for(points, point_type = @type)
605
+ case point_type
549
606
  when :scatter then points.map { |point| [point[:x], point[:y]] }
550
607
  when :bubble then points.map { |point| { x: point[:x], y: point[:y], z: point[:z] || 1 } }
551
608
  when :range_bar
552
609
  # Both ends stay in the point. A range read off an axis is only half a
553
610
  # range, and the label names the row rather than a position on a scale.
554
- points.map { |point| { x: point[:x], y: Array(point[:y]).map { |edge| range_edge(edge) } } }
611
+ points.map do |point|
612
+ range = { x: point[:x], y: Array(point[:y]).map { |edge| range_edge(edge) } }
613
+ range[:meta] = point[:meta] if point.key?(:meta)
614
+ range[:name] = point[:name] if point.key?(:name)
615
+ range
616
+ end
555
617
  else
556
618
  # A registered type may draw its own labels rather than read them off an
557
619
  # axis, in which case flattening the point to a value loses them.
558
- return points.map { |point| { x: point[:x], y: point[:y] } } if labelled_points?
620
+ return points.map { |point| preserved_point(point) } if preserved_points?(point_type) || preserve_combo_points?(points)
621
+ return aligned_values(points) if misaligned_series?
559
622
 
560
623
  points.map { |point| point[:y] }
561
624
  end
562
625
  end
563
626
 
564
- def labelled_points?
565
- RailsuiCharts.config.labelled_point_types.include?(@type)
627
+ # A slot per shared category, nil where this series has nothing. Apex draws
628
+ # a nil as a gap, which is what a forecast that begins after the history
629
+ # ends should look like.
630
+ def aligned_values(points)
631
+ lookup = points.each_with_object({}) do |point, memo|
632
+ memo[point[:x]] = point[:y] if point.is_a?(Hash)
633
+ end
634
+
635
+ category_union.map { |category| lookup[category] }
636
+ end
637
+
638
+ def preserve_combo_points?(points)
639
+ complex_combo? && points.any? { |point| point[:x].present? }
640
+ end
641
+
642
+ def preserved_points?(type)
643
+ RailsuiCharts.config.labelled_point_types.include?(type) ||
644
+ RailsuiCharts.config.complex_point_types.include?(type)
645
+ end
646
+
647
+ def preserved_point(point)
648
+ point.slice(:x, :y, :z, :meta, :name, :label, :v, :points).compact
566
649
  end
567
650
 
568
651
  # Apex plots a range against a time axis in milliseconds. Handing it a Time
@@ -591,7 +674,7 @@ module RailsuiCharts
591
674
  return { labels: { show: false } } if circular?
592
675
  return numeric_axis(:x) if numeric_xaxis?
593
676
 
594
- {
677
+ options = {
595
678
  categories: axis_categories,
596
679
  labels: {
597
680
  show: categories.any? && !sparkline?,
@@ -609,6 +692,14 @@ module RailsuiCharts
609
692
  tooltip: { enabled: false },
610
693
  type: "category"
611
694
  }
695
+
696
+ # A complex combo carries its own x in every point, and a derived category
697
+ # list alongside numeric pair data makes Apex plot nothing. An explicit
698
+ # one is different: Apex reads xaxis.categories ahead of everything else,
699
+ # and without it a combo takes its whole axis from the first series alone
700
+ # — which draws every other series from position zero.
701
+ options = options.except(:categories) if complex_combo? && @options[:categories].blank?
702
+ options
612
703
  end
613
704
 
614
705
  # Scatter and bubble plot real x values. Handing Apex a `categories` array
@@ -773,10 +864,10 @@ module RailsuiCharts
773
864
  timeline? || @type == :bar
774
865
  end
775
866
 
776
- # Two scales, so the axis is coloured like its series. One scale keeps the
777
- # quiet neutral: there is nothing to tell apart.
778
- def axis_ink(index)
779
- resolve_var(categorical_palette[index] || config_color(:text))
867
+ # Series colour belongs to the marks and legend. Keep both scales neutral
868
+ # so the numbers stay readable and do not compete with the data.
869
+ def axis_ink(_index)
870
+ config_color(:text)
780
871
  end
781
872
 
782
873
  # Both sides fitted to their own values, then cut into the same number of
@@ -840,7 +931,7 @@ module RailsuiCharts
840
931
 
841
932
  def axis_values(side)
842
933
  values = @series.select { |series| axis_side(series) == side }
843
- .flat_map { |series| series[:data].map { |point| point[:y] } }
934
+ .flat_map { |series| series[:data].flat_map { |point| Array(point[:y]) } }
844
935
  # A column grows from a baseline, so its scale has to contain one. A line
845
936
  # can float, and forcing zero on a churn rate hovering near 3% would
846
937
  # flatten it against the top of the plot.
@@ -978,7 +1069,13 @@ module RailsuiCharts
978
1069
  # A mixed chart takes its shape from each series, and Apex wants the
979
1070
  # chart-level type to be "line" while they do — set to "bar" it draws
980
1071
  # every series as bars whatever they asked for.
981
- return "line" if combo?
1072
+ if combo?
1073
+ complex = @series.map.with_index { |series, index| series_point_type(series, index) }
1074
+ .find { |type| RailsuiCharts.config.complex_point_types.include?(type) }
1075
+ return apex_type_for(complex) if complex
1076
+
1077
+ return "line"
1078
+ end
982
1079
 
983
1080
  apex_type_for(@type)
984
1081
  end
@@ -987,6 +1084,8 @@ module RailsuiCharts
987
1084
  case type&.to_sym
988
1085
  when :sparkline then "line"
989
1086
  when :range_bar then "rangeBar"
1087
+ when :range_area then "rangeArea"
1088
+ when :box_plot then "boxPlot"
990
1089
  when :column, :bar then "bar"
991
1090
  when :donut then "donut"
992
1091
  when :polar_area then "polarArea"
@@ -6,6 +6,11 @@ module RailsuiCharts
6
6
  CIRCULAR_TYPES = %i[pie donut polar_area radar].freeze
7
7
 
8
8
  def railsui_chart(data, type: :line, **options)
9
+ # A chart whose series are not the numbers a reader wants can say so.
10
+ # Deleted rather than read, so it never reaches the builder and never
11
+ # gets serialised into the Stimulus value alongside the real config.
12
+ explicit_table = options.delete(:accessible_table)
13
+
9
14
  # An empty dataset is a normal day one, not an error. Rendering axes
10
15
  # around nothing looks like a chart that failed rather than a chart with
11
16
  # nothing to show yet.
@@ -14,7 +19,14 @@ module RailsuiCharts
14
19
  config = ApexOptionsBuilder.new(data, type: type, **options).build
15
20
  id = options[:id] || "rui-chart-#{SecureRandom.hex(4)}"
16
21
 
17
- table = options[:accessible] != false ? accessibility_table(config, id: id) : nil
22
+ table =
23
+ if options[:accessible] == false
24
+ nil
25
+ elsif explicit_table.present?
26
+ explicit_accessibility_table(explicit_table, id: id)
27
+ else
28
+ accessibility_table(config, id: id)
29
+ end
18
30
 
19
31
  content_tag(:div,
20
32
  id: id,
@@ -80,23 +92,38 @@ module RailsuiCharts
80
92
  end
81
93
 
82
94
  # Server-rendered placeholder for a chart whose data has not arrived yet —
83
- # the thing a Turbo frame shows before it swaps in the real one. Pass the
84
- # `type:` it will become so the placeholder is the shape being waited on.
85
- def railsui_chart_skeleton(height: nil, type: :line, label: "Loading chart")
86
- classes = ["railsui-chart-skeleton"]
87
- classes << "railsui-chart-skeleton--circular" if CIRCULAR_TYPES.include?(type.to_sym)
95
+ # the thing a Turbo frame shows before it swaps in the real one. It keeps
96
+ # the chart footprint and avoids drawing fake chart geometry before data
97
+ # exists.
98
+ def railsui_chart_skeleton(height: nil, type: :line, label: "Loading chart", align: :left)
99
+ alignment = skeleton_alignment(align)
88
100
 
89
101
  content_tag(:div,
90
- class: "railsui-chart-state railsui-chart-state--loading",
102
+ class: "railsui-chart-state railsui-chart-state--loading railsui-chart-state--loading-#{alignment}",
91
103
  style: state_height(height),
92
104
  role: "status",
93
105
  aria: { label: label, busy: true }) do
94
- content_tag(:span, "", class: classes.join(" "), aria: { hidden: true })
106
+ content_tag(:div, class: "railsui-chart-skeleton", aria: { hidden: true }) do
107
+ safe_join([
108
+ content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--label"),
109
+ content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--value"),
110
+ content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--meta"),
111
+ content_tag(:span, "", class: "railsui-chart-skeleton__plot"),
112
+ content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--footer")
113
+ ])
114
+ end
95
115
  end
96
116
  end
97
117
 
98
118
  private
99
119
 
120
+ def skeleton_alignment(align)
121
+ alignment = align.to_sym
122
+ return alignment if %i[left center right].include?(alignment)
123
+
124
+ raise ArgumentError, "Unsupported skeleton alignment: #{align}. Supported: left, center, right"
125
+ end
126
+
100
127
  def chart_state(kind, title:, description:, height:)
101
128
  content_tag(:div,
102
129
  class: "railsui-chart-state railsui-chart-state--#{kind}",
@@ -137,6 +164,35 @@ module RailsuiCharts
137
164
 
138
165
  # Every chart ships a visually hidden table so no value is reachable only by
139
166
  # hovering a mark.
167
+ # The table a caller supplied, rather than one derived from the series.
168
+ #
169
+ # This is the accessible half of an extension point: a downstream helper
170
+ # that stacks scaffolding series to get a shape Apex can draw — a waterfall
171
+ # holding bars at a running total, say — can still tell a screen reader
172
+ # what the chart means. The CSV export is built from this table too, so it
173
+ # gets the same correction for free.
174
+ def explicit_accessibility_table(table, id:)
175
+ headers = Array(table[:headers] || table["headers"])
176
+ rows = Array(table[:rows] || table["rows"])
177
+ return if rows.blank?
178
+
179
+ content_tag(:table, class: "sr-only", aria: { label: "Chart data" }) do
180
+ safe_join([
181
+ content_tag(:caption, "Data for chart #{id}"),
182
+ content_tag(:thead) do
183
+ content_tag(:tr) { safe_join(headers.map { |header| content_tag(:th, header) }) }
184
+ end,
185
+ content_tag(:tbody) do
186
+ safe_join(rows.map do |row|
187
+ content_tag(:tr) do
188
+ safe_join(Array(row).map { |cell| content_tag(:td, cell_value(cell)) })
189
+ end
190
+ end)
191
+ end
192
+ ])
193
+ end
194
+ end
195
+
140
196
  def accessibility_table(config, id:)
141
197
  series = config[:series]
142
198
  return if series.blank?
@@ -148,7 +204,18 @@ module RailsuiCharts
148
204
  else
149
205
  columns = Array(series).map { |s| { name: s[:name] || "Value", data: s[:data] } }
150
206
  categories = config.dig(:xaxis, :categories) || []
207
+
208
+ if categories.blank?
209
+ point_categories = accessibility_point_categories(columns)
210
+ if point_categories.present?
211
+ categories = point_categories
212
+ columns = columns.map do |column|
213
+ column.merge(data: accessibility_align_points(column[:data], categories))
214
+ end
215
+ end
216
+ end
151
217
  end
218
+ comparison_categories = config[:compare_categories] || []
152
219
 
153
220
  rows = columns.first&.dig(:data)
154
221
  return if rows.blank?
@@ -159,7 +226,7 @@ module RailsuiCharts
159
226
  content_tag(:thead) do
160
227
  content_tag(:tr) do
161
228
  safe_join(
162
- [content_tag(:th, "Category")] +
229
+ accessibility_category_headers(comparison_categories) +
163
230
  columns.map { |column| content_tag(:th, column[:name]) }
164
231
  )
165
232
  end
@@ -169,7 +236,7 @@ module RailsuiCharts
169
236
  rows.each_with_index.map do |_, index|
170
237
  content_tag(:tr) do
171
238
  safe_join(
172
- [content_tag(:td, categories[index] || index + 1)] +
239
+ accessibility_category_cells(categories, comparison_categories, index) +
173
240
  columns.map { |column| content_tag(:td, cell_value(column[:data][index])) }
174
241
  )
175
242
  end
@@ -180,6 +247,32 @@ module RailsuiCharts
180
247
  end
181
248
  end
182
249
 
250
+ def accessibility_category_headers(comparison_categories)
251
+ headers = [content_tag(:th, "Category")]
252
+ headers << content_tag(:th, "Comparison category") if comparison_categories.present?
253
+ headers
254
+ end
255
+
256
+ def accessibility_point_categories(columns)
257
+ columns.flat_map do |column|
258
+ Array(column[:data]).filter_map { |value| value[:x] if value.is_a?(Hash) && value.key?(:x) }
259
+ end.uniq
260
+ end
261
+
262
+ def accessibility_align_points(data, categories)
263
+ points = Array(data).each_with_object({}) do |value, indexed|
264
+ indexed[value[:x]] = value if value.is_a?(Hash) && value.key?(:x)
265
+ end
266
+
267
+ categories.map { |category| points[category] }
268
+ end
269
+
270
+ def accessibility_category_cells(categories, comparison_categories, index)
271
+ cells = [content_tag(:td, categories[index] || index + 1)]
272
+ cells << content_tag(:td, comparison_categories[index] || index + 1) if comparison_categories.present?
273
+ cells
274
+ end
275
+
183
276
  def cell_value(value)
184
277
  case value
185
278
  when Array then value.join(", ")
@@ -187,7 +280,9 @@ module RailsuiCharts
187
280
  # A bubble carries three numbers that all mean something here. Anything
188
281
  # else keeps its label in the category column already, so repeating it
189
282
  # beside the value just reads as "A, 6" to a screen reader.
190
- value.key?(:z) ? value.values_at(:x, :y, :z).compact.join(", ") : value[:y]
283
+ return value.values_at(:x, :y, :z).compact.join(", ") if value.key?(:z)
284
+
285
+ cell_value(value[:y])
191
286
  else value
192
287
  end
193
288
  end
@@ -3,7 +3,7 @@
3
3
  module RailsuiCharts
4
4
  class Configuration
5
5
  attr_accessor :default_height, :default_currency, :colors, :series_colors, :theme_css_prefix, :extra_types
6
- attr_accessor :labelled_point_types, :typography, :geometry
6
+ attr_accessor :labelled_point_types, :complex_point_types, :typography, :geometry
7
7
 
8
8
  # Categorical hues carry identity, so the order is the colourblind-safety
9
9
  # mechanism rather than a style choice — it was picked by validating every
@@ -27,6 +27,7 @@ module RailsuiCharts
27
27
  # Chart types registered by an extension, such as Rails UI Charts Pro.
28
28
  @extra_types = []
29
29
  @labelled_point_types = []
30
+ @complex_point_types = []
30
31
  @theme_css_prefix = "--rui-chart"
31
32
  @series_colors = (1..SERIES_COUNT).map { |i| "var(--rui-chart-series-#{i}, #{SERIES_FALLBACKS[i - 1]})" }
32
33
  @colors = {
@@ -76,11 +77,16 @@ module RailsuiCharts
76
77
  # `points: :labelled` keeps each point as {x:, y:}. Most types put the label
77
78
  # on the axis and send bare values, but a treemap draws its labels inside
78
79
  # the rectangles, so they have to stay in the data.
80
+ #
81
+ # `points: :complex` keeps the whole point too, but for a different reason:
82
+ # some Apex types put several numbers in one y-value, such as OHLC candles,
83
+ # box plots, and range areas.
79
84
  def register_type(type, points: :values)
80
85
  type = type.to_sym
81
86
 
82
87
  @extra_types |= [type]
83
88
  @labelled_point_types |= [type] if points == :labelled
89
+ @complex_point_types |= [type] if points == :complex
84
90
 
85
91
  type
86
92
  end
@@ -146,27 +146,49 @@ module RailsuiCharts
146
146
  def metric_dialog(label, value, change, previous, history, compare, format, positive_is_good, options)
147
147
  content_tag(:dialog,
148
148
  class: "railsui-metric-dialog",
149
- data: { "railsui-metric-dialog-target": "dialog", action: "click->railsui-metric-dialog#closeOnBackdrop" }) do
149
+ data: {
150
+ "railsui-metric-dialog-target": "dialog",
151
+ action: "click->railsui-metric-dialog#closeOnBackdrop cancel->railsui-metric-dialog#cancel"
152
+ }) do
150
153
  content_tag(:div, class: "railsui-metric-dialog__panel") do
151
154
  safe_join([
152
155
  content_tag(:div, class: "railsui-metric-dialog__head") do
153
156
  safe_join([
154
157
  metric_card_head(label, value, change, previous, format, positive_is_good),
155
- content_tag(:button, "&times;".html_safe, type: "button", class: "railsui-metric-dialog__close",
156
- aria: { label: "Close" }, data: { action: "railsui-metric-dialog#close" })
158
+ content_tag(:button, metric_dialog_close_icon, type: "button", class: "railsui-metric-dialog__close",
159
+ aria: { label: "Close" }, data: { action: "railsui-metric-dialog#close" })
157
160
  ])
158
161
  end,
159
162
  # Room to read values rather than just a trend, so the axis labels
160
- # the card suppresses come back. They centre on their data point,
161
- # so the outermost two need room or Apex clips them at the canvas.
162
- railsui_chart(history, type: :line, compare: compare, format: format, height: 420,
163
- axis: :right, legend: { show: compare.present? },
164
- grid: { padding: { left: 28, right: 12 } }, **options)
163
+ # the card suppresses come back. The header keeps the exact value;
164
+ # the axis uses compact labels so it stays legible inside the
165
+ # dialog without clipping at the canvas edge.
166
+ content_tag(:div, class: "railsui-metric-dialog__chart") do
167
+ railsui_chart(history, type: :line, compare: compare, format: metric_dialog_chart_format(format), height: 420,
168
+ axis: :right, legend: { show: compare.present? },
169
+ yaxis: { labels: { minWidth: 48, maxWidth: 72, offsetX: 4 } },
170
+ grid: { padding: { left: 32, right: 72 } }, **options)
171
+ end
165
172
  ])
166
173
  end
167
174
  end
168
175
  end
169
176
 
177
+ def metric_dialog_chart_format(format)
178
+ format == :currency ? :short_currency : format
179
+ end
180
+
181
+ def metric_dialog_close_icon
182
+ content_tag(:svg,
183
+ content_tag(:path, "", "stroke-linecap": "round", "stroke-linejoin": "round", d: "M6 18 18 6M6 6l12 12"),
184
+ class: "railsui-metric-dialog__close-icon",
185
+ viewBox: "0 0 24 24",
186
+ fill: "none",
187
+ stroke: "currentColor",
188
+ "stroke-width": 2,
189
+ aria: { hidden: true })
190
+ end
191
+
170
192
  def format_metric_value(value, format)
171
193
  case format
172
194
  when :currency, :short_currency
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsuiCharts
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrailsui/charts",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Rails-native chart components built on ApexCharts. Stimulus controllers for the railsui_charts gem.",
5
5
  "author": "Andy Leverenz <railsui@justalever.com>",
6
6
  "license": "MIT",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railsui_charts
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Leverenz