@casualoffice/sheets 0.17.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casualoffice/sheets",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Casual Sheets SDK — signing pipeline, iframe postMessage protocol, and CasualSheets React wrapper around Univer.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,534 @@
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
+ * ConditionalFormattingDialog — the SDK chrome's built-in Conditional Formatting
19
+ * modal. Sibling of DataValidationDialog / FormatCellsDialog: it reads the active
20
+ * A1 selection off the FUniver facade, gathers a rule via a small form, and
21
+ * applies it through the `@univerjs/sheets-conditional-formatting` facade.
22
+ *
23
+ * Pattern (grounded in
24
+ * `sheets-conditional-formatting/lib/types/facade/*.d.ts`):
25
+ * worksheet.newConditionalFormattingRule() // FConditionalFormattingBuilder
26
+ * .<condition>() // → ConditionalFormatHighlightRuleBuilder
27
+ * .setBackground(fill) / .setFontColor(text)
28
+ * .setRanges([range.getRange()])
29
+ * .build() // → IConditionFormattingRule
30
+ * worksheet.addConditionalFormattingRule(rule)
31
+ *
32
+ * Condition families (each → a real builder method, verified in
33
+ * f-conditional-formatting-builder.d.ts):
34
+ * - Cell value: greater / greaterEqual / less / lessEqual / equal / notEqual /
35
+ * between / notBetween → `.whenNumber*` (L461-609)
36
+ * - Text contains → `.whenTextContains(text)` (L630)
37
+ * - Top N / Bottom N → `.setRank({ isBottom, isPercent, value })` (L227)
38
+ * - Color scale → `.setColorScale(config)` (L853) — its own visual,
39
+ * no fill/text format
40
+ *
41
+ * The "Clear rules" action calls `worksheet.clearConditionalFormatRules()`
42
+ * (f-worksheet.d.ts L172) — the whole-sheet clear the facade exposes (there's no
43
+ * range-scoped clear on FWorksheet; FRange.clearConditionalFormatRules exists but
44
+ * is deprecated — see limitations).
45
+ *
46
+ * Mounted by `<DialogHost>` when `openDialog('conditional-formatting')` is called
47
+ * and no host override is registered.
48
+ */
49
+
50
+ import { useMemo, useState, type CSSProperties } from 'react';
51
+ // Side-effect import: installs `FWorksheet.newConditionalFormattingRule /
52
+ // addConditionalFormattingRule / clearConditionalFormatRules` (and the FRange
53
+ // mixin) on the facade prototypes AND augments the `@univerjs/sheets/facade`
54
+ // FWorksheet/FRange types. The `cf` plugin group registers the plugin but does
55
+ // NOT import this facade module, so without it the builder calls below are
56
+ // undefined at runtime. Mirrors DataValidationDialog's `/facade` side-effect.
57
+ import '@univerjs/sheets-conditional-formatting/facade';
58
+ import type { DialogComponentProps } from './extensions';
59
+ import type { CasualSheetsAPI } from '../sheets/api';
60
+ import { Dialog } from './Dialog';
61
+ import {
62
+ DIALOG_BTN_PRIMARY_STYLE,
63
+ DIALOG_BTN_SECONDARY_STYLE,
64
+ DIALOG_FIELD_STYLE,
65
+ DIALOG_INPUT_STYLE,
66
+ DIALOG_LABEL_STYLE,
67
+ } from './dialog-styles';
68
+
69
+ /** Rule families the dialog can build. */
70
+ type RuleType = 'cellValue' | 'textContains' | 'topN' | 'bottomN' | 'colorScale';
71
+
72
+ /** Operators for the cell-value family. */
73
+ type Operator =
74
+ | 'greater'
75
+ | 'greaterEqual'
76
+ | 'less'
77
+ | 'lessEqual'
78
+ | 'equal'
79
+ | 'notEqual'
80
+ | 'between'
81
+ | 'notBetween';
82
+
83
+ const RULE_TYPE_OPTIONS: Array<{ value: RuleType; label: string }> = [
84
+ { value: 'cellValue', label: 'Cell value' },
85
+ { value: 'textContains', label: 'Text contains' },
86
+ { value: 'topN', label: 'Top N' },
87
+ { value: 'bottomN', label: 'Bottom N' },
88
+ { value: 'colorScale', label: 'Color scale' },
89
+ ];
90
+
91
+ const OPERATOR_OPTIONS: Array<{ value: Operator; label: string }> = [
92
+ { value: 'greater', label: 'Greater than' },
93
+ { value: 'greaterEqual', label: 'Greater than or equal to' },
94
+ { value: 'less', label: 'Less than' },
95
+ { value: 'lessEqual', label: 'Less than or equal to' },
96
+ { value: 'equal', label: 'Equal to' },
97
+ { value: 'notEqual', label: 'Not equal to' },
98
+ { value: 'between', label: 'Between' },
99
+ { value: 'notBetween', label: 'Not between' },
100
+ ];
101
+
102
+ /** True when the operator needs a second operand. */
103
+ function isRangeOperator(op: Operator): boolean {
104
+ return op === 'between' || op === 'notBetween';
105
+ }
106
+
107
+ interface DialogState {
108
+ ruleType: RuleType;
109
+ operator: Operator;
110
+ /** Cell-value first operand (raw string; parsed on apply). */
111
+ operand1: string;
112
+ /** Second operand, used only for between / notBetween. */
113
+ operand2: string;
114
+ /** Substring for the text-contains family. */
115
+ text: string;
116
+ /** N for top/bottom N. */
117
+ rankN: string;
118
+ /** Interpret rankN as a percent instead of a count. */
119
+ rankPercent: boolean;
120
+ /** Highlight fill color. */
121
+ fillColor: string;
122
+ /** Highlight text color. */
123
+ textColor: string;
124
+ /** Color-scale endpoints (2-color: min → max). */
125
+ scaleMinColor: string;
126
+ scaleMaxColor: string;
127
+ }
128
+
129
+ const INITIAL_STATE: DialogState = {
130
+ ruleType: 'cellValue',
131
+ operator: 'greater',
132
+ operand1: '',
133
+ operand2: '',
134
+ text: '',
135
+ rankN: '10',
136
+ rankPercent: false,
137
+ fillColor: '#fce8b2',
138
+ textColor: '#7f6000',
139
+ scaleMinColor: '#ffffff',
140
+ scaleMaxColor: '#57bb8a',
141
+ };
142
+
143
+ /** The active FRange, or null when there is no selection. */
144
+ function activeRange(api: CasualSheetsAPI) {
145
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
146
+ }
147
+
148
+ /** The active FWorksheet, or null. */
149
+ function activeSheet(api: CasualSheetsAPI) {
150
+ return api.univer.getActiveWorkbook()?.getActiveSheet() ?? null;
151
+ }
152
+
153
+ /** True when the rule family carries a fill/text highlight format. */
154
+ function usesHighlight(type: RuleType): boolean {
155
+ return type !== 'colorScale';
156
+ }
157
+
158
+ /**
159
+ * Build a conditional-formatting rule from the form via the facade builder and
160
+ * add it to the active sheet, scoped to the active range. Returns false when
161
+ * there's no selection or the inputs are unusable.
162
+ */
163
+ function applyRule(api: CasualSheetsAPI, s: DialogState): boolean {
164
+ const range = activeRange(api);
165
+ const sheet = activeSheet(api);
166
+ if (!range || !sheet) return false;
167
+
168
+ // The conditional-formatting facade contributes these to FWorksheet at
169
+ // runtime; typed loosely here so the SDK doesn't hard-depend on the ambient
170
+ // FWorksheet augmentation at this call site.
171
+ const cfSheet = sheet as unknown as {
172
+ newConditionalFormattingRule: () => CfBuilder;
173
+ addConditionalFormattingRule: (rule: unknown) => unknown;
174
+ };
175
+ const iRange = (range as unknown as { getRange: () => unknown }).getRange();
176
+
177
+ const n1 = Number(s.operand1);
178
+ const n2 = Number(s.operand2);
179
+ const rankN = Number(s.rankN);
180
+
181
+ let builder = cfSheet.newConditionalFormattingRule();
182
+
183
+ switch (s.ruleType) {
184
+ case 'cellValue': {
185
+ if (!Number.isFinite(n1)) return false;
186
+ switch (s.operator) {
187
+ case 'greater':
188
+ builder = builder.whenNumberGreaterThan(n1);
189
+ break;
190
+ case 'greaterEqual':
191
+ builder = builder.whenNumberGreaterThanOrEqualTo(n1);
192
+ break;
193
+ case 'less':
194
+ builder = builder.whenNumberLessThan(n1);
195
+ break;
196
+ case 'lessEqual':
197
+ builder = builder.whenNumberLessThanOrEqualTo(n1);
198
+ break;
199
+ case 'equal':
200
+ builder = builder.whenNumberEqualTo(n1);
201
+ break;
202
+ case 'notEqual':
203
+ builder = builder.whenNumberNotEqualTo(n1);
204
+ break;
205
+ case 'between':
206
+ if (!Number.isFinite(n2)) return false;
207
+ builder = builder.whenNumberBetween(n1, n2);
208
+ break;
209
+ case 'notBetween':
210
+ if (!Number.isFinite(n2)) return false;
211
+ builder = builder.whenNumberNotBetween(n1, n2);
212
+ break;
213
+ }
214
+ break;
215
+ }
216
+ case 'textContains': {
217
+ const text = s.text.trim();
218
+ if (text.length === 0) return false;
219
+ builder = builder.whenTextContains(text);
220
+ break;
221
+ }
222
+ case 'topN':
223
+ case 'bottomN': {
224
+ if (!Number.isFinite(rankN) || rankN <= 0) return false;
225
+ builder = builder.setRank({
226
+ isBottom: s.ruleType === 'bottomN',
227
+ isPercent: s.rankPercent,
228
+ value: rankN,
229
+ });
230
+ break;
231
+ }
232
+ case 'colorScale': {
233
+ // 2-color scale: min endpoint → max endpoint. Value types 'min'/'max'
234
+ // are the CFValueType string literals (base/const.d.ts CFValueType).
235
+ builder = builder.setColorScale([
236
+ { index: 0, color: s.scaleMinColor, value: { type: 'min' } },
237
+ { index: 1, color: s.scaleMaxColor, value: { type: 'max' } },
238
+ ]);
239
+ break;
240
+ }
241
+ }
242
+
243
+ if (usesHighlight(s.ruleType)) {
244
+ builder = builder.setBackground(s.fillColor);
245
+ builder = builder.setFontColor(s.textColor);
246
+ }
247
+
248
+ const rule = builder.setRanges([iRange]).build();
249
+ cfSheet.addConditionalFormattingRule(rule);
250
+ return true;
251
+ }
252
+
253
+ /**
254
+ * The subset of the conditional-formatting builder chain we use, typed here so
255
+ * the call site is checked without importing the facade's private builder
256
+ * classes. Every method is verified in
257
+ * `sheets-conditional-formatting/lib/types/facade/f-conditional-formatting-builder.d.ts`.
258
+ */
259
+ interface CfBuilder {
260
+ whenNumberGreaterThan: (v: number) => CfBuilder;
261
+ whenNumberGreaterThanOrEqualTo: (v: number) => CfBuilder;
262
+ whenNumberLessThan: (v: number) => CfBuilder;
263
+ whenNumberLessThanOrEqualTo: (v: number) => CfBuilder;
264
+ whenNumberEqualTo: (v: number) => CfBuilder;
265
+ whenNumberNotEqualTo: (v: number) => CfBuilder;
266
+ whenNumberBetween: (a: number, b: number) => CfBuilder;
267
+ whenNumberNotBetween: (a: number, b: number) => CfBuilder;
268
+ whenTextContains: (t: string) => CfBuilder;
269
+ setRank: (config: { isBottom: boolean; isPercent: boolean; value: number }) => CfBuilder;
270
+ setColorScale: (
271
+ config: Array<{ index: number; color: string; value: { type: string; value?: number } }>,
272
+ ) => CfBuilder;
273
+ setBackground: (color?: string) => CfBuilder;
274
+ setFontColor: (color?: string) => CfBuilder;
275
+ setRanges: (ranges: unknown[]) => CfBuilder;
276
+ build: () => unknown;
277
+ }
278
+
279
+ const RANGE_NOTE_STYLE: CSSProperties = {
280
+ fontSize: 12,
281
+ color: 'var(--cs-chrome-muted, #605e5c)',
282
+ marginBottom: 12,
283
+ };
284
+
285
+ const CHECK_STYLE: CSSProperties = {
286
+ display: 'flex',
287
+ alignItems: 'center',
288
+ gap: 6,
289
+ marginBottom: 8,
290
+ cursor: 'pointer',
291
+ };
292
+
293
+ const COLOR_INPUT_STYLE: CSSProperties = {
294
+ width: 48,
295
+ height: 30,
296
+ padding: 2,
297
+ border: '1px solid var(--cs-chrome-border, #cdd3db)',
298
+ borderRadius: 6,
299
+ background: 'var(--cs-chrome-input-bg, #fff)',
300
+ cursor: 'pointer',
301
+ };
302
+
303
+ const TWO_COL_STYLE: CSSProperties = {
304
+ display: 'grid',
305
+ gridTemplateColumns: '1fr 1fr',
306
+ gap: 8,
307
+ };
308
+
309
+ export function ConditionalFormattingDialog({ api, onClose }: DialogComponentProps) {
310
+ const [state, setState] = useState<DialogState>(INITIAL_STATE);
311
+
312
+ // Read the selection once for the header hint. `getA1Notation` off the live
313
+ // FRange (verified in @univerjs/sheets/facade f-range.d.ts) gives the
314
+ // user-facing A1 label, e.g. "A1:B2".
315
+ const rangeLabel = useMemo(() => {
316
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
317
+ return fRange?.getA1Notation?.() ?? null;
318
+ }, [api]);
319
+
320
+ const hasSelection = activeRange(api) !== null;
321
+
322
+ const update = <K extends keyof DialogState>(key: K, value: DialogState[K]) =>
323
+ setState((prev) => ({ ...prev, [key]: value }));
324
+
325
+ const apply = () => {
326
+ if (applyRule(api, state)) onClose();
327
+ };
328
+
329
+ const clearRules = () => {
330
+ const sheet = activeSheet(api) as unknown as {
331
+ clearConditionalFormatRules?: () => unknown;
332
+ } | null;
333
+ sheet?.clearConditionalFormatRules?.();
334
+ onClose();
335
+ };
336
+
337
+ const showOperator = state.ruleType === 'cellValue';
338
+ const showSecondOperand = showOperator && isRangeOperator(state.operator);
339
+ const isRank = state.ruleType === 'topN' || state.ruleType === 'bottomN';
340
+
341
+ return (
342
+ <Dialog
343
+ title="Conditional formatting"
344
+ onClose={onClose}
345
+ width={440}
346
+ data-testid="cs-conditional-formatting-dialog"
347
+ footer={
348
+ <>
349
+ <button
350
+ type="button"
351
+ style={DIALOG_BTN_SECONDARY_STYLE}
352
+ data-testid="cs-conditional-formatting-clear"
353
+ onClick={clearRules}
354
+ >
355
+ Clear rules
356
+ </button>
357
+ <span style={{ flex: 1 }} />
358
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
359
+ Cancel
360
+ </button>
361
+ <button
362
+ type="button"
363
+ style={DIALOG_BTN_PRIMARY_STYLE}
364
+ data-testid="cs-conditional-formatting-apply"
365
+ disabled={!hasSelection}
366
+ onClick={apply}
367
+ >
368
+ Apply
369
+ </button>
370
+ </>
371
+ }
372
+ >
373
+ {hasSelection ? (
374
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-conditional-formatting-range">
375
+ Applies to <strong>{rangeLabel ?? 'the current selection'}</strong>
376
+ </div>
377
+ ) : (
378
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-conditional-formatting-no-selection">
379
+ Select one or more cells first, then reopen this dialog.
380
+ </div>
381
+ )}
382
+
383
+ <label style={DIALOG_FIELD_STYLE}>
384
+ <span style={DIALOG_LABEL_STYLE}>Condition type</span>
385
+ <select
386
+ style={DIALOG_INPUT_STYLE}
387
+ data-testid="cs-conditional-formatting-type"
388
+ value={state.ruleType}
389
+ onChange={(e) => update('ruleType', e.target.value as RuleType)}
390
+ >
391
+ {RULE_TYPE_OPTIONS.map((opt) => (
392
+ <option key={opt.value} value={opt.value}>
393
+ {opt.label}
394
+ </option>
395
+ ))}
396
+ </select>
397
+ </label>
398
+
399
+ {showOperator && (
400
+ <>
401
+ <label style={DIALOG_FIELD_STYLE}>
402
+ <span style={DIALOG_LABEL_STYLE}>Condition</span>
403
+ <select
404
+ style={DIALOG_INPUT_STYLE}
405
+ data-testid="cs-conditional-formatting-operator"
406
+ value={state.operator}
407
+ onChange={(e) => update('operator', e.target.value as Operator)}
408
+ >
409
+ {OPERATOR_OPTIONS.map((opt) => (
410
+ <option key={opt.value} value={opt.value}>
411
+ {opt.label}
412
+ </option>
413
+ ))}
414
+ </select>
415
+ </label>
416
+
417
+ <div style={showSecondOperand ? TWO_COL_STYLE : undefined}>
418
+ <label style={DIALOG_FIELD_STYLE}>
419
+ <span style={DIALOG_LABEL_STYLE}>{showSecondOperand ? 'Minimum' : 'Value'}</span>
420
+ <input
421
+ style={DIALOG_INPUT_STYLE}
422
+ data-testid="cs-conditional-formatting-operand1"
423
+ type="number"
424
+ value={state.operand1}
425
+ onChange={(e) => update('operand1', e.target.value)}
426
+ />
427
+ </label>
428
+ {showSecondOperand && (
429
+ <label style={DIALOG_FIELD_STYLE}>
430
+ <span style={DIALOG_LABEL_STYLE}>Maximum</span>
431
+ <input
432
+ style={DIALOG_INPUT_STYLE}
433
+ data-testid="cs-conditional-formatting-operand2"
434
+ type="number"
435
+ value={state.operand2}
436
+ onChange={(e) => update('operand2', e.target.value)}
437
+ />
438
+ </label>
439
+ )}
440
+ </div>
441
+ </>
442
+ )}
443
+
444
+ {state.ruleType === 'textContains' && (
445
+ <label style={DIALOG_FIELD_STYLE}>
446
+ <span style={DIALOG_LABEL_STYLE}>Text contains</span>
447
+ <input
448
+ style={DIALOG_INPUT_STYLE}
449
+ data-testid="cs-conditional-formatting-text"
450
+ value={state.text}
451
+ placeholder="e.g. urgent"
452
+ onChange={(e) => update('text', e.target.value)}
453
+ />
454
+ </label>
455
+ )}
456
+
457
+ {isRank && (
458
+ <>
459
+ <label style={DIALOG_FIELD_STYLE}>
460
+ <span style={DIALOG_LABEL_STYLE}>
461
+ {state.ruleType === 'topN' ? 'Top' : 'Bottom'}{' '}
462
+ {state.rankPercent ? 'percent' : 'count'}
463
+ </span>
464
+ <input
465
+ style={DIALOG_INPUT_STYLE}
466
+ data-testid="cs-conditional-formatting-rank-n"
467
+ type="number"
468
+ min={1}
469
+ value={state.rankN}
470
+ onChange={(e) => update('rankN', e.target.value)}
471
+ />
472
+ </label>
473
+ <label style={CHECK_STYLE}>
474
+ <input
475
+ type="checkbox"
476
+ data-testid="cs-conditional-formatting-rank-percent"
477
+ checked={state.rankPercent}
478
+ onChange={(e) => update('rankPercent', e.target.checked)}
479
+ />
480
+ <span>Interpret as percent</span>
481
+ </label>
482
+ </>
483
+ )}
484
+
485
+ {state.ruleType === 'colorScale' ? (
486
+ <div style={TWO_COL_STYLE}>
487
+ <label style={DIALOG_FIELD_STYLE}>
488
+ <span style={DIALOG_LABEL_STYLE}>Min color</span>
489
+ <input
490
+ style={COLOR_INPUT_STYLE}
491
+ data-testid="cs-conditional-formatting-scale-min"
492
+ type="color"
493
+ value={state.scaleMinColor}
494
+ onChange={(e) => update('scaleMinColor', e.target.value)}
495
+ />
496
+ </label>
497
+ <label style={DIALOG_FIELD_STYLE}>
498
+ <span style={DIALOG_LABEL_STYLE}>Max color</span>
499
+ <input
500
+ style={COLOR_INPUT_STYLE}
501
+ data-testid="cs-conditional-formatting-scale-max"
502
+ type="color"
503
+ value={state.scaleMaxColor}
504
+ onChange={(e) => update('scaleMaxColor', e.target.value)}
505
+ />
506
+ </label>
507
+ </div>
508
+ ) : (
509
+ <div style={TWO_COL_STYLE}>
510
+ <label style={DIALOG_FIELD_STYLE}>
511
+ <span style={DIALOG_LABEL_STYLE}>Fill color</span>
512
+ <input
513
+ style={COLOR_INPUT_STYLE}
514
+ data-testid="cs-conditional-formatting-fill-color"
515
+ type="color"
516
+ value={state.fillColor}
517
+ onChange={(e) => update('fillColor', e.target.value)}
518
+ />
519
+ </label>
520
+ <label style={DIALOG_FIELD_STYLE}>
521
+ <span style={DIALOG_LABEL_STYLE}>Text color</span>
522
+ <input
523
+ style={COLOR_INPUT_STYLE}
524
+ data-testid="cs-conditional-formatting-text-color"
525
+ type="color"
526
+ value={state.textColor}
527
+ onChange={(e) => update('textColor', e.target.value)}
528
+ />
529
+ </label>
530
+ </div>
531
+ )}
532
+ </Dialog>
533
+ );
534
+ }