@casualoffice/sheets 0.16.0 → 0.18.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,185 @@
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
+ /**
18
+ * InsertCellsDialog — the SDK chrome's built-in Insert Cells modal.
19
+ *
20
+ * Follows the DataValidationDialog / FormatCellsDialog pattern: reads the active
21
+ * A1 selection off the FUniver facade, offers the four Excel/Sheets shift
22
+ * choices, and applies via real, installed facade methods:
23
+ *
24
+ * - Shift cells right → `FRange.insertCells(Dimension.COLUMNS)`
25
+ * - Shift cells down → `FRange.insertCells(Dimension.ROWS)`
26
+ * - Entire row → `FWorksheet.insertRows(startRow, rowCount)`
27
+ * - Entire column → `FWorksheet.insertColumns(startColumn, colCount)`
28
+ *
29
+ * The shift-direction ↔ Dimension mapping is grounded in the `insertCells`
30
+ * doc-example in `@univerjs/sheets/lib/types/facade/f-range.d.ts` (line 1484):
31
+ * `insertCells(Dimension.COLUMNS)` pushes existing data to the RIGHT, and
32
+ * `insertCells(Dimension.ROWS)` pushes it DOWN. `Dimension` is verified in
33
+ * `@univerjs/core/lib/types/types/enum/dimension.d.ts` (COLUMNS=0, ROWS=1).
34
+ * `FWorksheet.insertRows` / `insertColumns` are verified in
35
+ * `@univerjs/sheets/lib/types/facade/f-worksheet.d.ts` (lines 355 / 679), and
36
+ * the range extents come from `FRange.getRow/getColumn/getLastRow/getLastColumn`
37
+ * (f-range.d.ts lines 115/141/128/154).
38
+ *
39
+ * Mounted by `<DialogHost>` when `openDialog('insert-cells')` is called and no
40
+ * host override is registered.
41
+ */
42
+
43
+ import { useMemo, useState, type CSSProperties } from 'react';
44
+ import { Dimension } from '@univerjs/core';
45
+ import type { DialogComponentProps } from './extensions';
46
+ import type { CasualSheetsAPI } from '../sheets/api';
47
+ import { Dialog } from './Dialog';
48
+ import { DIALOG_BTN_PRIMARY_STYLE, DIALOG_BTN_SECONDARY_STYLE } from './dialog-styles';
49
+
50
+ /** The four shift choices, matching Excel / Google Sheets "Insert cells". */
51
+ type ShiftChoice = 'right' | 'down' | 'row' | 'column';
52
+
53
+ const SHIFT_OPTIONS: Array<{ value: ShiftChoice; label: string }> = [
54
+ { value: 'right', label: 'Shift cells right' },
55
+ { value: 'down', label: 'Shift cells down' },
56
+ { value: 'row', label: 'Entire row' },
57
+ { value: 'column', label: 'Entire column' },
58
+ ];
59
+
60
+ /** The active FRange, or null when there is no selection. */
61
+ function activeRange(api: CasualSheetsAPI) {
62
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
63
+ }
64
+
65
+ /**
66
+ * Insert cells at the active selection with the chosen shift. Returns false when
67
+ * there is no active range (nothing to insert against).
68
+ */
69
+ function applyInsert(api: CasualSheetsAPI, shift: ShiftChoice): boolean {
70
+ const range = activeRange(api);
71
+ if (!range) return false;
72
+
73
+ switch (shift) {
74
+ case 'right':
75
+ // COLUMNS dimension pushes existing data to the right (f-range.d.ts:1484).
76
+ range.insertCells(Dimension.COLUMNS);
77
+ return true;
78
+ case 'down':
79
+ // ROWS dimension pushes existing data down.
80
+ range.insertCells(Dimension.ROWS);
81
+ return true;
82
+ case 'row': {
83
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
84
+ if (!sheet) return false;
85
+ const startRow = range.getRow();
86
+ const rowCount = range.getLastRow() - startRow + 1;
87
+ sheet.insertRows(startRow, rowCount);
88
+ return true;
89
+ }
90
+ case 'column': {
91
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
92
+ if (!sheet) return false;
93
+ const startColumn = range.getColumn();
94
+ const colCount = range.getLastColumn() - startColumn + 1;
95
+ sheet.insertColumns(startColumn, colCount);
96
+ return true;
97
+ }
98
+ }
99
+ }
100
+
101
+ const RANGE_NOTE_STYLE: CSSProperties = {
102
+ fontSize: 12,
103
+ color: 'var(--cs-chrome-muted, #605e5c)',
104
+ marginBottom: 14,
105
+ };
106
+
107
+ const RADIO_STYLE: CSSProperties = {
108
+ display: 'flex',
109
+ alignItems: 'center',
110
+ gap: 8,
111
+ marginBottom: 10,
112
+ cursor: 'pointer',
113
+ };
114
+
115
+ export function InsertCellsDialog({ api, onClose }: DialogComponentProps) {
116
+ const [shift, setShift] = useState<ShiftChoice>('down');
117
+
118
+ // Read the selection once for the header hint. `getA1Notation` off the live
119
+ // FRange (verified in @univerjs/sheets/facade f-range.d.ts) gives the
120
+ // user-facing A1 label, e.g. "A1:B2".
121
+ const rangeLabel = useMemo(() => {
122
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
123
+ return fRange?.getA1Notation?.() ?? null;
124
+ }, [api]);
125
+
126
+ const hasSelection = activeRange(api) !== null;
127
+
128
+ const apply = () => {
129
+ if (applyInsert(api, shift)) onClose();
130
+ };
131
+
132
+ return (
133
+ <Dialog
134
+ title="Insert cells"
135
+ onClose={onClose}
136
+ width={380}
137
+ data-testid="cs-insert-cells-dialog"
138
+ footer={
139
+ <>
140
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
141
+ Cancel
142
+ </button>
143
+ <button
144
+ type="button"
145
+ style={DIALOG_BTN_PRIMARY_STYLE}
146
+ data-testid="cs-insert-cells-apply"
147
+ disabled={!hasSelection}
148
+ onClick={apply}
149
+ >
150
+ Insert
151
+ </button>
152
+ </>
153
+ }
154
+ >
155
+ {hasSelection ? (
156
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-insert-cells-range">
157
+ Insert at <strong>{rangeLabel ?? 'the current selection'}</strong>
158
+ </div>
159
+ ) : (
160
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-insert-cells-no-selection">
161
+ Select one or more cells first, then reopen this dialog.
162
+ </div>
163
+ )}
164
+
165
+ <div role="radiogroup" aria-label="Shift direction">
166
+ {SHIFT_OPTIONS.map((opt) => (
167
+ <label
168
+ key={opt.value}
169
+ style={RADIO_STYLE}
170
+ data-testid={`cs-insert-cells-shift-${opt.value}-label`}
171
+ >
172
+ <input
173
+ type="radio"
174
+ name="cs-insert-cells-shift"
175
+ data-testid={`cs-insert-cells-shift-${opt.value}`}
176
+ checked={shift === opt.value}
177
+ onChange={() => setShift(opt.value)}
178
+ />
179
+ <span>{opt.label}</span>
180
+ </label>
181
+ ))}
182
+ </div>
183
+ </Dialog>
184
+ );
185
+ }
@@ -0,0 +1,490 @@
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
+ /**
18
+ * InsertChartDialog — the SDK chrome's built-in Insert Chart modal.
19
+ *
20
+ * Reads the active A1 selection off the FUniver facade, lets the user pick a
21
+ * chart type (column / bar / line / pie) and confirm the source range, then
22
+ * inserts a chart over the grid.
23
+ *
24
+ * Facade grounding / capability note
25
+ * ----------------------------------
26
+ * Univer 0.25 (the version pinned in this SDK's peerDependencies) ships NO
27
+ * `@univerjs/*chart*` package — there is no `FUniver.newChart()` builder and no
28
+ * `FRange.insertChart()` the way `@univerjs/sheets-data-validation` gives us
29
+ * `newDataValidation()` / `setDataValidation()`. (Verified: no chart package
30
+ * under node_modules/@univerjs, and none listed in the SDK peerDependencies.)
31
+ *
32
+ * The closest REAL, installed facade capability for placing a positioned visual
33
+ * artifact over the grid is the float-DOM API contributed by
34
+ * `@univerjs/sheets-drawing-ui/facade` — the same mechanism Univer's own chart
35
+ * and image plugins use to anchor content over cells:
36
+ * - `univerAPI.registerComponent(key, Component)` (@univerjs/ui f-univer.d.ts L390)
37
+ * - `FWorksheet.addFloatDomToRange(range, { componentKey, data }, domLayout, id)`
38
+ * (@univerjs/sheets-drawing-ui/lib/types/facade/f-worksheet.d.ts L305; the
39
+ * `declare module '@univerjs/sheets/facade' { interface FWorksheet … }`
40
+ * augmentation at L428 is what puts the method on the live sheet)
41
+ *
42
+ * So on Apply we: register a small self-contained SVG chart-preview component
43
+ * (once), read the selected range's numeric values off the SDK snapshot, and
44
+ * insert a float DOM over the selection rendering that preview for the chosen
45
+ * type. This is a genuine facade insertion (real, disposable, positioned),
46
+ * reads the real selection data, and closes.
47
+ *
48
+ * LIMITATION (recorded): without a chart engine in the SDK's dep tree, the
49
+ * inserted artifact is a static, computed SVG preview of the selected values —
50
+ * not a live, re-calculating, fully-styled chart series. When a host installs a
51
+ * real Univer chart plugin it can OVERRIDE this dialog by registering its own
52
+ * `insert-chart` extension (extensions.dialogs['insert-chart']); the built-in is
53
+ * the always-available default.
54
+ *
55
+ * Mounted by `<DialogHost>` when `openDialog('insert-chart')` is called and no
56
+ * host override is registered.
57
+ */
58
+
59
+ import { useMemo, useState, type CSSProperties, type ReactNode } from 'react';
60
+ // Side-effect import: installs `FWorksheet.addFloatDomToRange` (+ the other
61
+ // float-dom methods) on the facade prototype AND augments
62
+ // `@univerjs/sheets/facade`'s FWorksheet at type-check time. Without it, the
63
+ // method is undefined at runtime. Mirrors DataValidationDialog's
64
+ // `@univerjs/sheets-data-validation/facade` side-effect import.
65
+ import '@univerjs/sheets-drawing-ui/facade';
66
+ import type { DialogComponentProps } from './extensions';
67
+ import type { CasualSheetsAPI } from '../sheets/api';
68
+ import { Dialog } from './Dialog';
69
+ import {
70
+ DIALOG_BTN_PRIMARY_STYLE,
71
+ DIALOG_BTN_SECONDARY_STYLE,
72
+ DIALOG_FIELD_STYLE,
73
+ DIALOG_INPUT_STYLE,
74
+ DIALOG_LABEL_STYLE,
75
+ } from './dialog-styles';
76
+
77
+ /** Chart families the dialog can insert. */
78
+ type ChartType = 'column' | 'bar' | 'line' | 'pie';
79
+
80
+ const CHART_TYPE_OPTIONS: Array<{ value: ChartType; label: string }> = [
81
+ { value: 'column', label: 'Column' },
82
+ { value: 'bar', label: 'Bar' },
83
+ { value: 'line', label: 'Line' },
84
+ { value: 'pie', label: 'Pie' },
85
+ ];
86
+
87
+ /** Stable component key we register the preview under (once per FUniver). */
88
+ const CHART_COMPONENT_KEY = 'cs-builtin-chart-preview';
89
+
90
+ /** Palette for the preview series (kept small + colour-blind-friendly-ish). */
91
+ const PALETTE = ['#0e7490', '#f59e0b', '#10b981', '#ef4444', '#6366f1', '#ec4899'];
92
+
93
+ interface DialogState {
94
+ chartType: ChartType;
95
+ /** A1 range the chart reads from (seeded from the live selection). */
96
+ rangeA1: string;
97
+ }
98
+
99
+ /** The active FRange, or null when there is no selection. */
100
+ function activeRange(api: CasualSheetsAPI) {
101
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
102
+ }
103
+
104
+ /** The active FWorksheet, or null. */
105
+ function activeSheet(api: CasualSheetsAPI) {
106
+ return api.univer.getActiveWorkbook()?.getActiveSheet() ?? null;
107
+ }
108
+
109
+ /**
110
+ * Pull the numeric values out of the selected range off the SDK snapshot (the
111
+ * same read path FormatCellsDialog uses to seed itself). Flattened + coerced;
112
+ * non-numeric cells drop out. Returns [] when nothing usable is present.
113
+ */
114
+ function readRangeNumbers(api: CasualSheetsAPI): number[] {
115
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
116
+ const snap = api.getSnapshot() as any;
117
+ const sel = api.getSelection();
118
+ if (!snap || !sel) return [];
119
+ const sheet = snap.sheets?.[sel.sheetId];
120
+ if (!sheet) return [];
121
+ const { startRow, endRow, startColumn, endColumn } = sel.range;
122
+ const out: number[] = [];
123
+ for (let r = startRow; r <= endRow; r++) {
124
+ for (let c = startColumn; c <= endColumn; c++) {
125
+ const raw = sheet.cellData?.[r]?.[c]?.v;
126
+ const n = typeof raw === 'number' ? raw : Number(raw);
127
+ if (Number.isFinite(n)) out.push(n);
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Register the SVG chart-preview component on the FUniver facade if it hasn't
135
+ * been registered yet. Idempotent per FUniver instance: we stash a flag on the
136
+ * facade object so re-opening the dialog doesn't re-register (registerComponent
137
+ * returns a disposable, but we intentionally keep the registration alive for the
138
+ * lifetime of the editor so previously-inserted charts keep rendering).
139
+ */
140
+ function ensureChartComponent(api: CasualSheetsAPI): void {
141
+ const uni = api.univer as unknown as {
142
+ registerComponent?: (key: string, comp: unknown) => { dispose(): void };
143
+ __csChartRegistered?: boolean;
144
+ };
145
+ if (uni.__csChartRegistered || typeof uni.registerComponent !== 'function') return;
146
+ uni.registerComponent(CHART_COMPONENT_KEY, ChartPreview);
147
+ uni.__csChartRegistered = true;
148
+ }
149
+
150
+ /**
151
+ * Insert the chart over the current selection via the drawing-ui float-DOM
152
+ * facade. Returns false when there's no selection or the facade method is
153
+ * missing (host didn't register the drawing-ui plugin). Reads the range's
154
+ * numbers and hands them to the preview component as `data`.
155
+ */
156
+ function insertChart(api: CasualSheetsAPI, s: DialogState): boolean {
157
+ const range = activeRange(api);
158
+ const sheet = activeSheet(api);
159
+ if (!range || !sheet) return false;
160
+
161
+ ensureChartComponent(api);
162
+
163
+ const values = readRangeNumbers(api);
164
+
165
+ // `addFloatDomToRange` is contributed by @univerjs/sheets-drawing-ui/facade
166
+ // (f-worksheet.d.ts L305). Typed loosely at the call site so the SDK doesn't
167
+ // hard-depend on the ambient FWorksheet augmentation here.
168
+ const domSheet = sheet as unknown as {
169
+ addFloatDomToRange?: (
170
+ range: unknown,
171
+ layer: { componentKey: string; data?: unknown; allowTransform?: boolean },
172
+ domLayout: {
173
+ width?: number;
174
+ height?: number;
175
+ marginX?: number | string;
176
+ marginY?: number | string;
177
+ },
178
+ id?: string,
179
+ ) => { id: string; dispose: () => void } | null;
180
+ };
181
+ if (typeof domSheet.addFloatDomToRange !== 'function') return false;
182
+
183
+ const result = domSheet.addFloatDomToRange(
184
+ range,
185
+ {
186
+ componentKey: CHART_COMPONENT_KEY,
187
+ allowTransform: true,
188
+ data: { type: s.chartType, values, rangeA1: s.rangeA1 },
189
+ },
190
+ { width: 360, height: 240, marginX: 0, marginY: 0 },
191
+ `cs-chart-${Date.now()}`,
192
+ );
193
+ return result != null;
194
+ }
195
+
196
+ /**
197
+ * Self-contained SVG chart preview rendered inside the float DOM. Univer passes
198
+ * the `layer.data` object through as the component's `data` prop. Deliberately
199
+ * dependency-free (no echarts) so the SDK bundle stays lean and the built-in
200
+ * works without any host chart engine.
201
+ */
202
+ function ChartPreview({
203
+ data,
204
+ }: {
205
+ data?: { type?: ChartType; values?: number[]; rangeA1?: string };
206
+ }) {
207
+ const type = data?.type ?? 'column';
208
+ const values = (data?.values ?? []).slice(0, 24);
209
+ const label = data?.rangeA1 ?? '';
210
+
211
+ const wrapStyle: CSSProperties = {
212
+ width: '100%',
213
+ height: '100%',
214
+ boxSizing: 'border-box',
215
+ background: '#ffffff',
216
+ border: '1px solid #cdd3db',
217
+ borderRadius: 6,
218
+ padding: 8,
219
+ display: 'flex',
220
+ flexDirection: 'column',
221
+ font: '12px system-ui, sans-serif',
222
+ color: '#201f1e',
223
+ overflow: 'hidden',
224
+ };
225
+ const titleStyle: CSSProperties = {
226
+ fontWeight: 600,
227
+ marginBottom: 4,
228
+ whiteSpace: 'nowrap',
229
+ overflow: 'hidden',
230
+ textOverflow: 'ellipsis',
231
+ };
232
+
233
+ const W = 340;
234
+ const H = 176;
235
+ const max = values.length ? Math.max(...values, 0) : 0;
236
+ const min = values.length ? Math.min(...values, 0) : 0;
237
+ const span = max - min || 1;
238
+
239
+ let body: ReactNode;
240
+ if (values.length === 0) {
241
+ body = (
242
+ <text x={W / 2} y={H / 2} textAnchor="middle" fill="#605e5c">
243
+ No numeric data in range
244
+ </text>
245
+ );
246
+ } else if (type === 'pie') {
247
+ const total = values.reduce((a, v) => a + Math.abs(v), 0) || 1;
248
+ const cx = W / 2;
249
+ const cy = H / 2;
250
+ const rad = Math.min(W, H) / 2 - 6;
251
+ let angle = -Math.PI / 2;
252
+ body = (
253
+ <>
254
+ {values.map((v, i) => {
255
+ const frac = Math.abs(v) / total;
256
+ const next = angle + frac * Math.PI * 2;
257
+ const x1 = cx + rad * Math.cos(angle);
258
+ const y1 = cy + rad * Math.sin(angle);
259
+ const x2 = cx + rad * Math.cos(next);
260
+ const y2 = cy + rad * Math.sin(next);
261
+ const large = frac > 0.5 ? 1 : 0;
262
+ const d = `M ${cx} ${cy} L ${x1} ${y1} A ${rad} ${rad} 0 ${large} 1 ${x2} ${y2} Z`;
263
+ angle = next;
264
+ return <path key={i} d={d} fill={PALETTE[i % PALETTE.length]} />;
265
+ })}
266
+ </>
267
+ );
268
+ } else if (type === 'line') {
269
+ const step = values.length > 1 ? W / (values.length - 1) : W;
270
+ const pts = values.map((v, i) => `${i * step},${H - ((v - min) / span) * H}`).join(' ');
271
+ body = <polyline points={pts} fill="none" stroke={PALETTE[0]} strokeWidth={2} />;
272
+ } else {
273
+ // column (vertical) or bar (horizontal)
274
+ const horizontal = type === 'bar';
275
+ const n = values.length;
276
+ const gap = 4;
277
+ body = (
278
+ <>
279
+ {values.map((v, i) => {
280
+ const fill = PALETTE[i % PALETTE.length];
281
+ if (horizontal) {
282
+ const bh = H / n - gap;
283
+ const bw = ((v - min) / span) * W;
284
+ return (
285
+ <rect
286
+ key={i}
287
+ x={0}
288
+ y={i * (bh + gap)}
289
+ width={Math.max(bw, 1)}
290
+ height={bh}
291
+ fill={fill}
292
+ />
293
+ );
294
+ }
295
+ const bw = W / n - gap;
296
+ const bh = ((v - min) / span) * H;
297
+ return (
298
+ <rect
299
+ key={i}
300
+ x={i * (bw + gap)}
301
+ y={H - bh}
302
+ width={bw}
303
+ height={Math.max(bh, 1)}
304
+ fill={fill}
305
+ />
306
+ );
307
+ })}
308
+ </>
309
+ );
310
+ }
311
+
312
+ return (
313
+ <div style={wrapStyle}>
314
+ {label && (
315
+ <div style={titleStyle}>{`${type[0].toUpperCase()}${type.slice(1)} chart · ${label}`}</div>
316
+ )}
317
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height="100%" preserveAspectRatio="xMidYMid meet">
318
+ {body}
319
+ </svg>
320
+ </div>
321
+ );
322
+ }
323
+
324
+ const RANGE_NOTE_STYLE: CSSProperties = {
325
+ fontSize: 12,
326
+ color: 'var(--cs-chrome-muted, #605e5c)',
327
+ marginBottom: 12,
328
+ };
329
+
330
+ const TYPE_ROW_STYLE: CSSProperties = {
331
+ display: 'grid',
332
+ gridTemplateColumns: 'repeat(4, 1fr)',
333
+ gap: 8,
334
+ marginBottom: 12,
335
+ };
336
+
337
+ function typeCardStyle(selected: boolean): CSSProperties {
338
+ return {
339
+ display: 'flex',
340
+ flexDirection: 'column',
341
+ alignItems: 'center',
342
+ gap: 6,
343
+ padding: '10px 4px',
344
+ border: `1px solid ${selected ? 'var(--cs-chrome-active-fg, #0e7490)' : 'var(--cs-chrome-border, #cdd3db)'}`,
345
+ borderRadius: 8,
346
+ background: selected
347
+ ? 'var(--cs-chrome-active-bg, #ecfeff)'
348
+ : 'var(--cs-chrome-input-bg, #fff)',
349
+ color: selected ? 'var(--cs-chrome-active-fg, #0e7490)' : 'var(--cs-chrome-fg, #201f1e)',
350
+ font: 'inherit',
351
+ fontSize: 12,
352
+ cursor: 'pointer',
353
+ };
354
+ }
355
+
356
+ /** Tiny inline SVG glyph per chart type — no icon-font dependency. */
357
+ function TypeGlyph({ type }: { type: ChartType }) {
358
+ const c = 'currentColor';
359
+ switch (type) {
360
+ case 'column':
361
+ return (
362
+ <svg width={22} height={22} viewBox="0 0 22 22" fill={c} aria-hidden="true">
363
+ <rect x={2} y={10} width={4} height={10} />
364
+ <rect x={9} y={5} width={4} height={15} />
365
+ <rect x={16} y={2} width={4} height={18} />
366
+ </svg>
367
+ );
368
+ case 'bar':
369
+ return (
370
+ <svg width={22} height={22} viewBox="0 0 22 22" fill={c} aria-hidden="true">
371
+ <rect x={2} y={2} width={10} height={4} />
372
+ <rect x={2} y={9} width={16} height={4} />
373
+ <rect x={2} y={16} width={7} height={4} />
374
+ </svg>
375
+ );
376
+ case 'line':
377
+ return (
378
+ <svg
379
+ width={22}
380
+ height={22}
381
+ viewBox="0 0 22 22"
382
+ fill="none"
383
+ stroke={c}
384
+ strokeWidth={2}
385
+ aria-hidden="true"
386
+ >
387
+ <polyline points="2,16 8,9 13,13 20,3" />
388
+ </svg>
389
+ );
390
+ case 'pie':
391
+ return (
392
+ <svg width={22} height={22} viewBox="0 0 22 22" fill={c} aria-hidden="true">
393
+ <path d="M11 11 L11 2 A9 9 0 0 1 20 11 Z" />
394
+ <circle cx={11} cy={11} r={9} fill="none" stroke={c} strokeWidth={1.5} />
395
+ </svg>
396
+ );
397
+ }
398
+ }
399
+
400
+ export function InsertChartDialog({ api, onClose }: DialogComponentProps) {
401
+ // Read the selection once for the header hint + as the seed source range.
402
+ // `getA1Notation` off the live FRange (verified in @univerjs/sheets/facade
403
+ // f-range.d.ts) gives the user-facing A1 label, e.g. "A1:B5".
404
+ const rangeLabel = useMemo(() => {
405
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
406
+ return fRange?.getA1Notation?.() ?? null;
407
+ }, [api]);
408
+
409
+ const [state, setState] = useState<DialogState>(() => ({
410
+ chartType: 'column',
411
+ rangeA1: rangeLabel ?? '',
412
+ }));
413
+
414
+ const hasSelection = activeRange(api) !== null;
415
+
416
+ const update = <K extends keyof DialogState>(key: K, value: DialogState[K]) =>
417
+ setState((prev) => ({ ...prev, [key]: value }));
418
+
419
+ const apply = () => {
420
+ if (insertChart(api, state)) onClose();
421
+ };
422
+
423
+ return (
424
+ <Dialog
425
+ title="Insert chart"
426
+ onClose={onClose}
427
+ width={440}
428
+ data-testid="cs-insert-chart-dialog"
429
+ footer={
430
+ <>
431
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
432
+ Cancel
433
+ </button>
434
+ <button
435
+ type="button"
436
+ style={DIALOG_BTN_PRIMARY_STYLE}
437
+ data-testid="cs-insert-chart-apply"
438
+ disabled={!hasSelection}
439
+ onClick={apply}
440
+ >
441
+ Insert
442
+ </button>
443
+ </>
444
+ }
445
+ >
446
+ {hasSelection ? (
447
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-insert-chart-range">
448
+ Chart from <strong>{rangeLabel ?? 'the current selection'}</strong>
449
+ </div>
450
+ ) : (
451
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-insert-chart-no-selection">
452
+ Select the data range first, then reopen this dialog.
453
+ </div>
454
+ )}
455
+
456
+ <div style={DIALOG_LABEL_STYLE}>Chart type</div>
457
+ <div style={TYPE_ROW_STYLE} role="radiogroup" aria-label="Chart type">
458
+ {CHART_TYPE_OPTIONS.map((opt) => (
459
+ <button
460
+ key={opt.value}
461
+ type="button"
462
+ role="radio"
463
+ aria-checked={state.chartType === opt.value}
464
+ data-testid={`cs-insert-chart-type-${opt.value}`}
465
+ style={typeCardStyle(state.chartType === opt.value)}
466
+ onClick={() => update('chartType', opt.value)}
467
+ >
468
+ <TypeGlyph type={opt.value} />
469
+ <span>{opt.label}</span>
470
+ </button>
471
+ ))}
472
+ </div>
473
+
474
+ <label style={DIALOG_FIELD_STYLE}>
475
+ <span style={DIALOG_LABEL_STYLE}>Data range</span>
476
+ <input
477
+ style={DIALOG_INPUT_STYLE}
478
+ data-testid="cs-insert-chart-range-input"
479
+ value={state.rangeA1}
480
+ placeholder="A1:B5"
481
+ onChange={(e) => update('rangeA1', e.target.value)}
482
+ />
483
+ </label>
484
+ <div style={RANGE_NOTE_STYLE}>
485
+ The chart is placed over the current selection. Numeric cells in the range become the
486
+ series.
487
+ </div>
488
+ </Dialog>
489
+ );
490
+ }