@casualoffice/sheets 0.18.0 → 0.19.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.
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { useState } from 'react';
18
+ import { Dialog } from '../chrome/Dialog';
19
+ import {
20
+ CHART_FAMILY_OF,
21
+ LEGEND_POSITIONS,
22
+ PALETTES,
23
+ PALETTE_LABELS,
24
+ mergeFormat,
25
+ type ChartFormat,
26
+ type ChartModel,
27
+ type ChartPalette,
28
+ } from './types';
29
+
30
+ type Props = {
31
+ model: ChartModel;
32
+ /** Optional list of series names — when present, the dialog renders
33
+ * a per-series colour-override picker below the palette section.
34
+ * Pulled by the caller from the chart's source range. */
35
+ seriesNames?: string[];
36
+ onCancel: () => void;
37
+ onConfirm: (next: ChartFormat) => void;
38
+ };
39
+
40
+ /**
41
+ * Excel's "Format Chart Area" pane, compressed into a single dialog
42
+ * tailored to what we currently render. Covers:
43
+ *
44
+ * - Chart title (text + show/hide).
45
+ * - Legend position (Bottom / Top / Left / Right / None).
46
+ * - X-axis title text + Y-axis title text (axis families only).
47
+ * - Major gridlines on/off (axis families only).
48
+ * - Data labels on/off.
49
+ * - Colour palette (Office / Mono / Vivid / Pastel).
50
+ *
51
+ * The dialog confirms with a merged `ChartFormat`; ChartContextMenu
52
+ * applies it to `model.format` via `update()`.
53
+ *
54
+ * The chart title text is `model.title` (auto-named "Chart N" on
55
+ * insert). Editing it here also returns it so the caller can persist
56
+ * the rename together with the format change.
57
+ */
58
+ export function FormatChartDialog({ model, seriesNames, onCancel, onConfirm }: Props) {
59
+ const merged = mergeFormat(model);
60
+ const family = CHART_FAMILY_OF[model.type];
61
+ const isAxisFamily = family !== 'pie';
62
+ // Combo (mix bar + line) and a secondary value axis only make sense
63
+ // on a vertical, absolute-scale chart: column / line / area. 100%-
64
+ // stacked variants pin every series to a shared 0–100% scale, and
65
+ // horizontal bars swap the axes so "secondary axis on the right"
66
+ // stops reading like Excel — mirror the gate in `build-option.ts`.
67
+ const is100 = model.type === 'column-stacked-100' || model.type === 'bar-stacked-100';
68
+ const isHorizontalBar = family === 'bar';
69
+ const supportsComboAndDualAxis =
70
+ isAxisFamily && family !== 'scatter' && !is100 && !isHorizontalBar;
71
+ const baseSeriesKind: 'bar' | 'line' = family === 'line' || family === 'area' ? 'line' : 'bar';
72
+ const [title, setTitle] = useState(model.title ?? '');
73
+ const [showTitle, setShowTitle] = useState(merged.showTitle);
74
+ const [legend, setLegend] = useState(merged.legend);
75
+ const [xAxisTitle, setXAxisTitle] = useState(merged.xAxisTitle ?? '');
76
+ const [yAxisTitle, setYAxisTitle] = useState(merged.yAxisTitle ?? '');
77
+ const [gridlines, setGridlines] = useState(merged.gridlines);
78
+ const [dataLabels, setDataLabels] = useState(merged.dataLabels);
79
+ const [palette, setPalette] = useState<ChartPalette>(merged.palette);
80
+ const [trendline, setTrendline] = useState(merged.trendline);
81
+ const [seriesColors, setSeriesColors] = useState<Record<string, string>>(
82
+ merged.seriesColors ?? {},
83
+ );
84
+ const [seriesTypes, setSeriesTypes] = useState<Record<string, 'bar' | 'line'>>(
85
+ merged.seriesTypes ?? {},
86
+ );
87
+ const [secondaryAxis, setSecondaryAxis] = useState<Record<string, boolean>>(
88
+ merged.secondaryAxis ?? {},
89
+ );
90
+
91
+ const confirm = () => {
92
+ // Strip empty / palette-matching overrides so the payload only
93
+ // carries explicit user picks.
94
+ const trimmedSeriesColors: Record<string, string> = {};
95
+ for (const [name, color] of Object.entries(seriesColors)) {
96
+ if (color && color.trim()) trimmedSeriesColors[name] = color;
97
+ }
98
+ // Only persist series-kind overrides that actually differ from the
99
+ // chart's base kind, and secondary-axis flags that are `true`, so
100
+ // the payload stays minimal (and toggling back to the default
101
+ // cleanly removes the override).
102
+ const trimmedSeriesTypes: Record<string, 'bar' | 'line'> = {};
103
+ if (supportsComboAndDualAxis) {
104
+ for (const [name, kind] of Object.entries(seriesTypes)) {
105
+ if (kind && kind !== baseSeriesKind) trimmedSeriesTypes[name] = kind;
106
+ }
107
+ }
108
+ const trimmedSecondaryAxis: Record<string, boolean> = {};
109
+ if (supportsComboAndDualAxis) {
110
+ for (const [name, on] of Object.entries(secondaryAxis)) {
111
+ if (on) trimmedSecondaryAxis[name] = true;
112
+ }
113
+ }
114
+ onConfirm({
115
+ showTitle,
116
+ legend,
117
+ ...(xAxisTitle.trim() ? { xAxisTitle: xAxisTitle.trim() } : { xAxisTitle: undefined }),
118
+ ...(yAxisTitle.trim() ? { yAxisTitle: yAxisTitle.trim() } : { yAxisTitle: undefined }),
119
+ gridlines,
120
+ dataLabels,
121
+ palette,
122
+ trendline,
123
+ seriesColors: trimmedSeriesColors,
124
+ seriesTypes: trimmedSeriesTypes,
125
+ secondaryAxis: trimmedSecondaryAxis,
126
+ // Title text is part of the chart's identity (`ChartModel.title`)
127
+ // not its format. The caller reads the trimmed title via the
128
+ // form input below and applies it alongside the format patch.
129
+ });
130
+ // Side-channel the title back via a custom event so the caller
131
+ // doesn't need a separate prop just for one optional string.
132
+ const trimmed = title.trim();
133
+ if (trimmed !== (model.title ?? '')) {
134
+ const ce = new CustomEvent('casual-chart-title-changed', {
135
+ detail: { id: model.id, title: trimmed || undefined },
136
+ });
137
+ document.dispatchEvent(ce);
138
+ }
139
+ };
140
+
141
+ return (
142
+ <Dialog
143
+ title="Format chart"
144
+ onClose={onCancel}
145
+ data-testid="format-chart-dialog"
146
+ footer={
147
+ <>
148
+ <button
149
+ type="button"
150
+ className="btn-secondary"
151
+ data-testid="format-chart-cancel"
152
+ onClick={onCancel}
153
+ >
154
+ Cancel
155
+ </button>
156
+ <button
157
+ type="button"
158
+ className="btn-primary"
159
+ data-testid="format-chart-apply"
160
+ onClick={confirm}
161
+ >
162
+ Apply
163
+ </button>
164
+ </>
165
+ }
166
+ >
167
+ <div className="format-chart">
168
+ <Section legend="Title">
169
+ <div className="format-chart__row">
170
+ <label className="format-chart__checkbox">
171
+ <input
172
+ type="checkbox"
173
+ checked={showTitle}
174
+ data-testid="format-chart-show-title"
175
+ onChange={(e) => setShowTitle(e.target.checked)}
176
+ />
177
+ <span>Show title</span>
178
+ </label>
179
+ </div>
180
+ <input
181
+ type="text"
182
+ className="format-chart__input"
183
+ data-testid="format-chart-title-input"
184
+ value={title}
185
+ onChange={(e) => setTitle(e.target.value)}
186
+ placeholder="Chart 1"
187
+ disabled={!showTitle}
188
+ />
189
+ </Section>
190
+
191
+ <Section legend="Legend">
192
+ <div className="format-chart__segment" role="radiogroup" aria-label="Legend position">
193
+ {LEGEND_POSITIONS.map((p) => (
194
+ <label
195
+ key={p.id}
196
+ className={`format-chart__seg-opt${legend === p.id ? ' format-chart__seg-opt--active' : ''}`}
197
+ data-testid={`format-chart-legend-${p.id}`}
198
+ >
199
+ <input
200
+ type="radio"
201
+ name="legend-pos"
202
+ value={p.id}
203
+ checked={legend === p.id}
204
+ onChange={() => setLegend(p.id)}
205
+ />
206
+ <span>{p.label}</span>
207
+ </label>
208
+ ))}
209
+ </div>
210
+ </Section>
211
+
212
+ {isAxisFamily && (
213
+ <Section legend="Axes">
214
+ <div className="format-chart__axis-row">
215
+ <label className="format-chart__field">
216
+ <span className="format-chart__field-label">X-axis title</span>
217
+ <input
218
+ type="text"
219
+ className="format-chart__input"
220
+ data-testid="format-chart-x-axis-title"
221
+ value={xAxisTitle}
222
+ onChange={(e) => setXAxisTitle(e.target.value)}
223
+ placeholder="Auto"
224
+ />
225
+ </label>
226
+ <label className="format-chart__field">
227
+ <span className="format-chart__field-label">Y-axis title</span>
228
+ <input
229
+ type="text"
230
+ className="format-chart__input"
231
+ data-testid="format-chart-y-axis-title"
232
+ value={yAxisTitle}
233
+ onChange={(e) => setYAxisTitle(e.target.value)}
234
+ placeholder="Auto"
235
+ />
236
+ </label>
237
+ </div>
238
+ <label className="format-chart__checkbox">
239
+ <input
240
+ type="checkbox"
241
+ checked={gridlines}
242
+ data-testid="format-chart-gridlines"
243
+ onChange={(e) => setGridlines(e.target.checked)}
244
+ />
245
+ <span>Show major gridlines</span>
246
+ </label>
247
+ </Section>
248
+ )}
249
+
250
+ <Section legend="Data labels">
251
+ <label className="format-chart__checkbox">
252
+ <input
253
+ type="checkbox"
254
+ checked={dataLabels}
255
+ data-testid="format-chart-data-labels"
256
+ onChange={(e) => setDataLabels(e.target.checked)}
257
+ />
258
+ <span>Show values on each point</span>
259
+ </label>
260
+ </Section>
261
+
262
+ {/* Trendline only meaningful for axis-based charts. Pie /
263
+ doughnut have no time axis, so the toggle is hidden. */}
264
+ {isAxisFamily && (
265
+ <Section legend="Trendline">
266
+ <label className="format-chart__checkbox">
267
+ <input
268
+ type="checkbox"
269
+ checked={trendline}
270
+ data-testid="format-chart-trendline"
271
+ onChange={(e) => setTrendline(e.target.checked)}
272
+ />
273
+ <span>Overlay linear-regression trendline on each series</span>
274
+ </label>
275
+ </Section>
276
+ )}
277
+
278
+ {/* Combo + dual axis — per-series render-kind (bar/line) and a
279
+ secondary value axis. Only shown for column / line / area
280
+ charts where it's meaningful, and when the caller supplied
281
+ series names. */}
282
+ {supportsComboAndDualAxis && seriesNames && seriesNames.length > 0 && (
283
+ <Section legend="Series type & axis">
284
+ <p className="format-chart__hint">
285
+ Mix bars and lines, and plot a series against a secondary (right) axis.
286
+ </p>
287
+ <div className="format-chart__series-rows">
288
+ {seriesNames.map((name, idx) => {
289
+ const kind = seriesTypes[name] ?? baseSeriesKind;
290
+ const onSecondary = Boolean(secondaryAxis[name]);
291
+ return (
292
+ <div
293
+ key={name}
294
+ className="format-chart__series-row"
295
+ data-testid={`format-chart-combo-${idx}`}
296
+ >
297
+ <span className="format-chart__series-name">{name}</span>
298
+ <select
299
+ className="format-chart__series-kind"
300
+ data-testid={`format-chart-series-kind-${idx}`}
301
+ aria-label={`${name} chart type`}
302
+ value={kind}
303
+ onChange={(e) =>
304
+ setSeriesTypes({
305
+ ...seriesTypes,
306
+ [name]: e.target.value as 'bar' | 'line',
307
+ })
308
+ }
309
+ >
310
+ <option value="bar">Bars</option>
311
+ <option value="line">Line</option>
312
+ </select>
313
+ <label className="format-chart__checkbox format-chart__series-secondary">
314
+ <input
315
+ type="checkbox"
316
+ data-testid={`format-chart-secondary-axis-${idx}`}
317
+ checked={onSecondary}
318
+ onChange={(e) =>
319
+ setSecondaryAxis({ ...secondaryAxis, [name]: e.target.checked })
320
+ }
321
+ />
322
+ <span>Secondary axis</span>
323
+ </label>
324
+ </div>
325
+ );
326
+ })}
327
+ </div>
328
+ </Section>
329
+ )}
330
+
331
+ <Section legend="Colors">
332
+ <div className="format-chart__palettes" role="radiogroup" aria-label="Color palette">
333
+ {(Object.keys(PALETTES) as ChartPalette[]).map((p) => (
334
+ <label
335
+ key={p}
336
+ className={`format-chart__palette${palette === p ? ' format-chart__palette--active' : ''}`}
337
+ data-testid={`format-chart-palette-${p}`}
338
+ title={PALETTE_LABELS[p]}
339
+ >
340
+ <input
341
+ type="radio"
342
+ name="palette"
343
+ value={p}
344
+ checked={palette === p}
345
+ onChange={() => setPalette(p)}
346
+ />
347
+ <div className="format-chart__palette-swatches">
348
+ {PALETTES[p].slice(0, 5).map((c) => (
349
+ <span
350
+ key={c}
351
+ className="format-chart__palette-swatch"
352
+ style={{ backgroundColor: c }}
353
+ />
354
+ ))}
355
+ </div>
356
+ <span className="format-chart__palette-label">{PALETTE_LABELS[p]}</span>
357
+ </label>
358
+ ))}
359
+ </div>
360
+ </Section>
361
+
362
+ {/* Per-series colour overrides — only rendered when the caller
363
+ supplied series names from the source range. Each row pairs
364
+ a colour swatch (native picker) with a Reset button that
365
+ removes the override and falls back to the palette. */}
366
+ {seriesNames && seriesNames.length > 0 && (
367
+ <Section legend="Series colors">
368
+ <div className="format-chart__series-rows">
369
+ {seriesNames.map((name, idx) => {
370
+ const override = seriesColors[name] ?? '';
371
+ const defaultColor = PALETTES[palette][idx % PALETTES[palette].length];
372
+ return (
373
+ <div
374
+ key={name}
375
+ className="format-chart__series-row"
376
+ data-testid={`format-chart-series-${idx}`}
377
+ >
378
+ <span className="format-chart__series-name">{name}</span>
379
+ <input
380
+ type="color"
381
+ className="format-chart__series-color"
382
+ data-testid={`format-chart-series-color-${idx}`}
383
+ value={override || defaultColor}
384
+ onChange={(e) => setSeriesColors({ ...seriesColors, [name]: e.target.value })}
385
+ />
386
+ {override && (
387
+ <button
388
+ type="button"
389
+ className="format-chart__series-reset"
390
+ data-testid={`format-chart-series-reset-${idx}`}
391
+ title="Reset to palette default"
392
+ onClick={() => {
393
+ const next = { ...seriesColors };
394
+ delete next[name];
395
+ setSeriesColors(next);
396
+ }}
397
+ >
398
+ Reset
399
+ </button>
400
+ )}
401
+ </div>
402
+ );
403
+ })}
404
+ </div>
405
+ </Section>
406
+ )}
407
+ </div>
408
+ </Dialog>
409
+ );
410
+ }
411
+
412
+ function Section({ legend, children }: { legend: string; children: React.ReactNode }) {
413
+ return (
414
+ <fieldset className="format-chart__group">
415
+ <legend className="format-chart__legend">{legend}</legend>
416
+ {children}
417
+ </fieldset>
418
+ );
419
+ }