@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,493 @@
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
+ * InsertFunctionDialog — the SDK chrome's built-in Insert Function picker.
19
+ *
20
+ * A searchable, category-grouped list of common spreadsheet functions. Picking a
21
+ * function seeds the active cell with `=NAME(` so the user can type arguments,
22
+ * then focuses the grid. Applying is grounded in the real Sheets FUniver facade:
23
+ * - `api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange()` gives
24
+ * the live FRange (see FormatCellsDialog `activeRange`);
25
+ * - `FRange.setValue('=NAME(')` writes the formula stub into the range's
26
+ * top-left cell (verified in `@univerjs/sheets/lib/types/facade/f-range.d.ts`
27
+ * L814 `setValue(value: CellValue | ICellData): FRange`);
28
+ * - `FRange.getA1Notation()` (f-range.d.ts L1220) labels the target cell;
29
+ * - `api.focus()` returns keyboard focus to the grid for argument entry.
30
+ *
31
+ * Note on the caret: the installed FRange facade has no "enter cell edit mode
32
+ * with the caret parked after the open-paren" method — `setValue` commits the
33
+ * cell content and Univer re-parses it. So on choose we write `=NAME(` and focus
34
+ * the grid; the user double-clicks / F2s to continue typing arguments. That's the
35
+ * closest real, installed behaviour (recorded in the dialog's limitations).
36
+ *
37
+ * Mounted by `<DialogHost>` when `openDialog('insert-function')` is called and no
38
+ * host override is registered.
39
+ */
40
+
41
+ import { useMemo, useState, type CSSProperties } from 'react';
42
+ import type { DialogComponentProps } from './extensions';
43
+ import type { CasualSheetsAPI } from '../sheets/api';
44
+ import { Dialog } from './Dialog';
45
+ import {
46
+ DIALOG_BTN_PRIMARY_STYLE,
47
+ DIALOG_BTN_SECONDARY_STYLE,
48
+ DIALOG_INPUT_STYLE,
49
+ } from './dialog-styles';
50
+
51
+ /** A single function entry in the picker. */
52
+ interface FunctionEntry {
53
+ /** Canonical function name (upper-case), inserted verbatim. */
54
+ name: string;
55
+ /** Category bucket for grouping. */
56
+ category: string;
57
+ /** One-line description shown under the list / in the preview. */
58
+ description: string;
59
+ /** Argument-signature hint shown in the preview, e.g. `SUM(value1, [value2, …])`. */
60
+ syntax: string;
61
+ }
62
+
63
+ /**
64
+ * The common-function catalog. Deliberately a focused, Google-Sheets-style
65
+ * "common functions" set rather than the full library — this is a quick-insert
66
+ * picker, not a reference. Grouped by `category`.
67
+ */
68
+ const FUNCTIONS: FunctionEntry[] = [
69
+ // Math
70
+ {
71
+ name: 'SUM',
72
+ category: 'Math',
73
+ description: 'Sum of a set of numbers.',
74
+ syntax: 'SUM(value1, [value2, …])',
75
+ },
76
+ {
77
+ name: 'PRODUCT',
78
+ category: 'Math',
79
+ description: 'Product of a set of numbers.',
80
+ syntax: 'PRODUCT(factor1, [factor2, …])',
81
+ },
82
+ {
83
+ name: 'ROUND',
84
+ category: 'Math',
85
+ description: 'Round a number to a given number of places.',
86
+ syntax: 'ROUND(value, [places])',
87
+ },
88
+ {
89
+ name: 'ABS',
90
+ category: 'Math',
91
+ description: 'Absolute value of a number.',
92
+ syntax: 'ABS(value)',
93
+ },
94
+ {
95
+ name: 'MOD',
96
+ category: 'Math',
97
+ description: 'Remainder after division.',
98
+ syntax: 'MOD(dividend, divisor)',
99
+ },
100
+ {
101
+ name: 'POWER',
102
+ category: 'Math',
103
+ description: 'A number raised to a power.',
104
+ syntax: 'POWER(base, exponent)',
105
+ },
106
+ // Statistical
107
+ {
108
+ name: 'AVERAGE',
109
+ category: 'Statistical',
110
+ description: 'Arithmetic mean of a set of numbers.',
111
+ syntax: 'AVERAGE(value1, [value2, …])',
112
+ },
113
+ {
114
+ name: 'COUNT',
115
+ category: 'Statistical',
116
+ description: 'Count of numeric values in a range.',
117
+ syntax: 'COUNT(value1, [value2, …])',
118
+ },
119
+ {
120
+ name: 'COUNTA',
121
+ category: 'Statistical',
122
+ description: 'Count of non-empty values in a range.',
123
+ syntax: 'COUNTA(value1, [value2, …])',
124
+ },
125
+ {
126
+ name: 'MAX',
127
+ category: 'Statistical',
128
+ description: 'Largest value in a set.',
129
+ syntax: 'MAX(value1, [value2, …])',
130
+ },
131
+ {
132
+ name: 'MIN',
133
+ category: 'Statistical',
134
+ description: 'Smallest value in a set.',
135
+ syntax: 'MIN(value1, [value2, …])',
136
+ },
137
+ {
138
+ name: 'MEDIAN',
139
+ category: 'Statistical',
140
+ description: 'Median of a set of numbers.',
141
+ syntax: 'MEDIAN(value1, [value2, …])',
142
+ },
143
+ // Logical
144
+ {
145
+ name: 'IF',
146
+ category: 'Logical',
147
+ description: 'Return one value if a condition is true, another if false.',
148
+ syntax: 'IF(condition, value_if_true, value_if_false)',
149
+ },
150
+ {
151
+ name: 'IFS',
152
+ category: 'Logical',
153
+ description: 'Test multiple conditions, return the first match.',
154
+ syntax: 'IFS(condition1, value1, [condition2, value2, …])',
155
+ },
156
+ {
157
+ name: 'AND',
158
+ category: 'Logical',
159
+ description: 'True when all arguments are true.',
160
+ syntax: 'AND(logical1, [logical2, …])',
161
+ },
162
+ {
163
+ name: 'OR',
164
+ category: 'Logical',
165
+ description: 'True when any argument is true.',
166
+ syntax: 'OR(logical1, [logical2, …])',
167
+ },
168
+ {
169
+ name: 'IFERROR',
170
+ category: 'Logical',
171
+ description: 'Return a fallback when a formula errors.',
172
+ syntax: 'IFERROR(value, value_if_error)',
173
+ },
174
+ // Lookup
175
+ {
176
+ name: 'VLOOKUP',
177
+ category: 'Lookup',
178
+ description: 'Search a column for a key, return a value from the same row.',
179
+ syntax: 'VLOOKUP(search_key, range, index, [is_sorted])',
180
+ },
181
+ {
182
+ name: 'HLOOKUP',
183
+ category: 'Lookup',
184
+ description: 'Search a row for a key, return a value from the same column.',
185
+ syntax: 'HLOOKUP(search_key, range, index, [is_sorted])',
186
+ },
187
+ {
188
+ name: 'INDEX',
189
+ category: 'Lookup',
190
+ description: 'Value at a given row/column offset in a range.',
191
+ syntax: 'INDEX(reference, [row], [column])',
192
+ },
193
+ {
194
+ name: 'MATCH',
195
+ category: 'Lookup',
196
+ description: 'Position of a value within a range.',
197
+ syntax: 'MATCH(search_key, range, [search_type])',
198
+ },
199
+ {
200
+ name: 'XLOOKUP',
201
+ category: 'Lookup',
202
+ description: 'Search a range and return a corresponding value.',
203
+ syntax: 'XLOOKUP(search_key, lookup_range, result_range)',
204
+ },
205
+ // Text
206
+ {
207
+ name: 'CONCATENATE',
208
+ category: 'Text',
209
+ description: 'Join strings end to end.',
210
+ syntax: 'CONCATENATE(string1, [string2, …])',
211
+ },
212
+ {
213
+ name: 'LEFT',
214
+ category: 'Text',
215
+ description: 'Leftmost characters of a string.',
216
+ syntax: 'LEFT(string, [num_chars])',
217
+ },
218
+ {
219
+ name: 'RIGHT',
220
+ category: 'Text',
221
+ description: 'Rightmost characters of a string.',
222
+ syntax: 'RIGHT(string, [num_chars])',
223
+ },
224
+ {
225
+ name: 'MID',
226
+ category: 'Text',
227
+ description: 'Characters from the middle of a string.',
228
+ syntax: 'MID(string, start, length)',
229
+ },
230
+ { name: 'LEN', category: 'Text', description: 'Length of a string.', syntax: 'LEN(string)' },
231
+ {
232
+ name: 'TRIM',
233
+ category: 'Text',
234
+ description: 'Remove leading/trailing/duplicate spaces.',
235
+ syntax: 'TRIM(string)',
236
+ },
237
+ // Date
238
+ { name: 'TODAY', category: 'Date', description: "Today's date.", syntax: 'TODAY()' },
239
+ { name: 'NOW', category: 'Date', description: 'Current date and time.', syntax: 'NOW()' },
240
+ {
241
+ name: 'DATE',
242
+ category: 'Date',
243
+ description: 'Build a date from year, month, day.',
244
+ syntax: 'DATE(year, month, day)',
245
+ },
246
+ {
247
+ name: 'DATEDIF',
248
+ category: 'Date',
249
+ description: 'Difference between two dates in a chosen unit.',
250
+ syntax: 'DATEDIF(start_date, end_date, unit)',
251
+ },
252
+ ];
253
+
254
+ /** Ordered category list, so groups render in a stable, sensible order. */
255
+ const CATEGORY_ORDER = ['Math', 'Statistical', 'Logical', 'Lookup', 'Text', 'Date'];
256
+
257
+ /** The active FRange, or null when there is no selection. */
258
+ function activeRange(api: CasualSheetsAPI) {
259
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
260
+ }
261
+
262
+ /**
263
+ * Insert `=NAME(` into the top-left cell of the active range via the facade,
264
+ * then focus the grid for argument entry. Returns false when there's no
265
+ * selection to write into.
266
+ */
267
+ function insertFunction(api: CasualSheetsAPI, fn: FunctionEntry): boolean {
268
+ const range = activeRange(api);
269
+ if (!range) return false;
270
+
271
+ // Zero-arg functions (TODAY / NOW) are complete on their own; for the rest,
272
+ // leave the open-paren so the user continues typing arguments.
273
+ const zeroArg = fn.syntax.endsWith('()');
274
+ const formula = zeroArg ? `=${fn.name}()` : `=${fn.name}(`;
275
+
276
+ // FRange.setValue writes into the range's top-left cell (f-range.d.ts L814).
277
+ range.setValue(formula);
278
+
279
+ // Return keyboard focus to the grid so the user can keep typing arguments.
280
+ api.focus();
281
+ return true;
282
+ }
283
+
284
+ const SEARCH_STYLE: CSSProperties = {
285
+ ...DIALOG_INPUT_STYLE,
286
+ width: '100%',
287
+ marginBottom: 12,
288
+ };
289
+
290
+ const RANGE_NOTE_STYLE: CSSProperties = {
291
+ fontSize: 12,
292
+ color: 'var(--cs-chrome-muted, #605e5c)',
293
+ marginBottom: 12,
294
+ };
295
+
296
+ const LIST_STYLE: CSSProperties = {
297
+ maxHeight: 280,
298
+ overflow: 'auto',
299
+ border: '1px solid var(--cs-chrome-border, #edeff3)',
300
+ borderRadius: 8,
301
+ };
302
+
303
+ const GROUP_HEADER_STYLE: CSSProperties = {
304
+ position: 'sticky',
305
+ top: 0,
306
+ padding: '6px 12px',
307
+ fontSize: 11,
308
+ fontWeight: 600,
309
+ letterSpacing: 0.4,
310
+ textTransform: 'uppercase',
311
+ color: 'var(--cs-chrome-muted, #605e5c)',
312
+ background: 'var(--cs-chrome-input-bg, #fff)',
313
+ borderBottom: '1px solid var(--cs-chrome-border, #edeff3)',
314
+ };
315
+
316
+ function itemStyle(selected: boolean): CSSProperties {
317
+ return {
318
+ display: 'block',
319
+ width: '100%',
320
+ textAlign: 'left',
321
+ padding: '8px 12px',
322
+ border: 'none',
323
+ borderBottom: '1px solid var(--cs-chrome-border, #f3f4f6)',
324
+ background: selected ? 'var(--cs-chrome-hover-bg, #eef6f8)' : 'transparent',
325
+ color: 'var(--cs-chrome-fg, #201f1e)',
326
+ font: 'inherit',
327
+ fontSize: 13,
328
+ cursor: 'pointer',
329
+ };
330
+ }
331
+
332
+ const ITEM_NAME_STYLE: CSSProperties = {
333
+ fontWeight: 600,
334
+ fontFamily: 'var(--cs-chrome-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
335
+ };
336
+
337
+ const ITEM_DESC_STYLE: CSSProperties = {
338
+ fontSize: 12,
339
+ color: 'var(--cs-chrome-muted, #605e5c)',
340
+ marginTop: 2,
341
+ };
342
+
343
+ const PREVIEW_STYLE: CSSProperties = {
344
+ marginTop: 12,
345
+ padding: '10px 12px',
346
+ borderRadius: 8,
347
+ background: 'var(--cs-chrome-subtle-bg, #f6f8fa)',
348
+ fontSize: 12,
349
+ color: 'var(--cs-chrome-fg, #201f1e)',
350
+ };
351
+
352
+ const PREVIEW_SYNTAX_STYLE: CSSProperties = {
353
+ fontFamily: 'var(--cs-chrome-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
354
+ fontWeight: 600,
355
+ marginBottom: 4,
356
+ };
357
+
358
+ export function InsertFunctionDialog({ api, onClose }: DialogComponentProps) {
359
+ const [query, setQuery] = useState('');
360
+ const [selectedName, setSelectedName] = useState<string | null>(null);
361
+
362
+ // Label the target cell once for the header hint.
363
+ const rangeLabel = useMemo(() => {
364
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
365
+ return fRange?.getA1Notation?.() ?? null;
366
+ }, [api]);
367
+
368
+ const hasSelection = activeRange(api) !== null;
369
+
370
+ // Filter by name / description / category, case-insensitive.
371
+ const filtered = useMemo(() => {
372
+ const q = query.trim().toLowerCase();
373
+ if (!q) return FUNCTIONS;
374
+ return FUNCTIONS.filter(
375
+ (fn) =>
376
+ fn.name.toLowerCase().includes(q) ||
377
+ fn.description.toLowerCase().includes(q) ||
378
+ fn.category.toLowerCase().includes(q),
379
+ );
380
+ }, [query]);
381
+
382
+ // Group the filtered set by category, preserving CATEGORY_ORDER.
383
+ const grouped = useMemo(() => {
384
+ const byCat = new Map<string, FunctionEntry[]>();
385
+ for (const fn of filtered) {
386
+ const list = byCat.get(fn.category);
387
+ if (list) list.push(fn);
388
+ else byCat.set(fn.category, [fn]);
389
+ }
390
+ return CATEGORY_ORDER.filter((c) => byCat.has(c)).map((c) => ({
391
+ category: c,
392
+ items: byCat.get(c)!,
393
+ }));
394
+ }, [filtered]);
395
+
396
+ const selected = useMemo(
397
+ () => FUNCTIONS.find((fn) => fn.name === selectedName) ?? null,
398
+ [selectedName],
399
+ );
400
+
401
+ const choose = (fn: FunctionEntry) => {
402
+ if (insertFunction(api, fn)) onClose();
403
+ };
404
+
405
+ const insertSelected = () => {
406
+ if (selected && insertFunction(api, selected)) onClose();
407
+ };
408
+
409
+ return (
410
+ <Dialog
411
+ title="Insert function"
412
+ onClose={onClose}
413
+ width={460}
414
+ data-testid="cs-insert-function-dialog"
415
+ footer={
416
+ <>
417
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
418
+ Cancel
419
+ </button>
420
+ <button
421
+ type="button"
422
+ style={DIALOG_BTN_PRIMARY_STYLE}
423
+ data-testid="cs-insert-function-insert"
424
+ disabled={!hasSelection || !selected}
425
+ onClick={insertSelected}
426
+ >
427
+ Insert
428
+ </button>
429
+ </>
430
+ }
431
+ >
432
+ {hasSelection ? (
433
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-insert-function-range">
434
+ Inserts into <strong>{rangeLabel ?? 'the active cell'}</strong>
435
+ </div>
436
+ ) : (
437
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-insert-function-no-selection">
438
+ Select a cell first, then reopen this dialog.
439
+ </div>
440
+ )}
441
+
442
+ <input
443
+ style={SEARCH_STYLE}
444
+ data-testid="cs-insert-function-search"
445
+ type="search"
446
+ placeholder="Search functions (name, category, or description)"
447
+ value={query}
448
+ autoFocus
449
+ onChange={(e) => setQuery(e.target.value)}
450
+ />
451
+
452
+ <div
453
+ style={LIST_STYLE}
454
+ role="listbox"
455
+ aria-label="Functions"
456
+ data-testid="cs-insert-function-list"
457
+ >
458
+ {grouped.length === 0 && (
459
+ <div style={{ padding: '12px', fontSize: 13, color: 'var(--cs-chrome-muted, #605e5c)' }}>
460
+ No functions match &ldquo;{query}&rdquo;.
461
+ </div>
462
+ )}
463
+ {grouped.map((group) => (
464
+ <div key={group.category}>
465
+ <div style={GROUP_HEADER_STYLE}>{group.category}</div>
466
+ {group.items.map((fn) => (
467
+ <button
468
+ key={fn.name}
469
+ type="button"
470
+ role="option"
471
+ aria-selected={selectedName === fn.name}
472
+ data-testid={`cs-insert-function-item-${fn.name}`}
473
+ style={itemStyle(selectedName === fn.name)}
474
+ onClick={() => setSelectedName(fn.name)}
475
+ onDoubleClick={() => choose(fn)}
476
+ >
477
+ <div style={ITEM_NAME_STYLE}>{fn.name}</div>
478
+ <div style={ITEM_DESC_STYLE}>{fn.description}</div>
479
+ </button>
480
+ ))}
481
+ </div>
482
+ ))}
483
+ </div>
484
+
485
+ {selected && (
486
+ <div style={PREVIEW_STYLE} data-testid="cs-insert-function-preview">
487
+ <div style={PREVIEW_SYNTAX_STYLE}>{selected.syntax}</div>
488
+ <div>{selected.description}</div>
489
+ </div>
490
+ )}
491
+ </Dialog>
492
+ );
493
+ }