@casualoffice/sheets 0.13.0 → 0.14.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.
@@ -1,16 +1,27 @@
1
1
  /**
2
2
  * Toolbar — the rich built-in formatting toolbar for `<CasualSheets chrome>`.
3
3
  *
4
- * Drives the editor purely through `CasualSheetsAPI.executeCommand`, with
5
- * Material Symbols icons + design-system tokens. No app context, no title/logo
6
- * row (the host frames the editor with its own bar). Commands grouped with
7
- * dividers, Office-style.
4
+ * Drives the editor purely through `CasualSheetsAPI.executeCommand` + the FUniver
5
+ * facade (`api.univer`), with Material Symbols icons + design-system tokens. No
6
+ * app context, no title/logo row (the host frames the editor with its own bar).
7
+ * Commands grouped with dividers, Office-style — mirrors the app shell's
8
+ * `apps/web/src/shell/Toolbar.tsx` + `RibbonControls.tsx` group/order/icons,
9
+ * replicating `home-tab-actions.ts` logic inline against `api` / `api.univer`.
8
10
  *
9
- * Covered: font family/size · undo/redo · bold/italic/underline/strikethrough ·
10
- * horizontal align · merge/unmerge · number formats (currency/percent/decimals).
11
- * Reflects the active cell toggles light up, dropdowns show the current font —
12
- * via a command-execution subscription. Follow-up: text/fill colour pickers
13
- * (need a swatch popover).
11
+ * Covered (parity with the app's Home tab):
12
+ * history (undo/redo) · clipboard (paste/cut/copy/paste-values) · format
13
+ * painter · font family/size + grow/shrink · bold/italic/underline/strike ·
14
+ * text + fill colour (ColorPicker) · borders (BordersPicker) · horizontal +
15
+ * vertical align · wrap · merge/unmerge · number-format dropdown +
16
+ * currency/percent/decimals · AutoSum (AutoSumPicker) · clear formatting.
17
+ *
18
+ * Reflects the active cell — toggles light up, dropdowns show the current font /
19
+ * number format — via a command-execution subscription.
20
+ *
21
+ * Dialog-only controls (Format Cells, Insert Chart, PivotTable) have no SDK
22
+ * dialog: when the host passes `onDialogRequest` they render and call it so the
23
+ * host can render its own; without it, they are omitted entirely (no fake
24
+ * dialog). Each group/control can also be hidden via the `features` flag map.
14
25
  */
15
26
 
16
27
  import { useEffect, useState, type CSSProperties } from 'react';
@@ -32,6 +43,7 @@ interface ActiveStyle {
32
43
  tb: number; // WrapStrategy of the active cell (0 = unset, 3 = wrap)
33
44
  ff: string;
34
45
  fs: number;
46
+ nf: string; // number-format pattern ('' = General)
35
47
  }
36
48
 
37
49
  const NO_STYLE: ActiveStyle = {
@@ -44,6 +56,7 @@ const NO_STYLE: ActiveStyle = {
44
56
  tb: 0,
45
57
  ff: '',
46
58
  fs: 0,
59
+ nf: '',
47
60
  };
48
61
 
49
62
  function readActiveStyle(api: CasualSheetsAPI): ActiveStyle {
@@ -63,11 +76,63 @@ function readActiveStyle(api: CasualSheetsAPI): ActiveStyle {
63
76
  tb: typeof s?.tb === 'number' ? s.tb : 0,
64
77
  ff: typeof s?.ff === 'string' ? s.ff : '',
65
78
  fs: typeof s?.fs === 'number' ? s.fs : 0,
79
+ nf: typeof s?.n?.pattern === 'string' ? s.n.pattern : '',
66
80
  };
67
81
  }
68
82
 
69
- const FONT_FAMILIES = ['Arial', 'Calibri', 'Times New Roman', 'Courier New', 'Georgia', 'Verdana'];
70
- const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36, 48, 72];
83
+ const FONT_FAMILIES = [
84
+ 'Calibri',
85
+ 'Arial',
86
+ 'Helvetica',
87
+ 'Inter',
88
+ 'Times New Roman',
89
+ 'Georgia',
90
+ 'Verdana',
91
+ 'Courier New',
92
+ 'JetBrains Mono',
93
+ ];
94
+ const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72];
95
+
96
+ /**
97
+ * Number-format presets, mirroring the app's `home-tab-actions`
98
+ * `NUMBER_FORMAT_PATTERNS`. The dropdown applies a pattern via the facade
99
+ * (`FRange.setNumberFormat`, a runtime numfmt-facade extension) so it lands as a
100
+ * normal numfmt mutation — same path the app uses.
101
+ */
102
+ const NUMBER_FORMAT_PATTERNS = {
103
+ general: '',
104
+ integer: '#,##0',
105
+ number: '#,##0.00',
106
+ currency: '"$"#,##0.00',
107
+ accounting: '_("$"* #,##0.00_);_("$"* (#,##0.00);_("$"* "-"??_);_(@_)',
108
+ percent: '0.00%',
109
+ date: 'yyyy-mm-dd',
110
+ time: 'hh:mm:ss',
111
+ scientific: '0.00E+00',
112
+ text: '@',
113
+ } as const;
114
+
115
+ type NumberFormatKey = keyof typeof NUMBER_FORMAT_PATTERNS;
116
+
117
+ const NUMBER_FORMAT_OPTIONS: { value: NumberFormatKey; label: string }[] = [
118
+ { value: 'general', label: 'General' },
119
+ { value: 'integer', label: 'Number (no decimals)' },
120
+ { value: 'number', label: 'Number (2 decimals)' },
121
+ { value: 'currency', label: 'Currency' },
122
+ { value: 'accounting', label: 'Accounting' },
123
+ { value: 'percent', label: 'Percent' },
124
+ { value: 'date', label: 'Date (yyyy-mm-dd)' },
125
+ { value: 'time', label: 'Time (hh:mm:ss)' },
126
+ { value: 'scientific', label: 'Scientific' },
127
+ { value: 'text', label: 'Text' },
128
+ ];
129
+
130
+ function detectFormatKey(pattern: string): NumberFormatKey {
131
+ for (const k of Object.keys(NUMBER_FORMAT_PATTERNS) as NumberFormatKey[]) {
132
+ if (NUMBER_FORMAT_PATTERNS[k] === pattern) return k;
133
+ }
134
+ return 'general';
135
+ }
71
136
 
72
137
  interface ToolbarAction {
73
138
  id: string;
@@ -75,138 +140,208 @@ interface ToolbarAction {
75
140
  command: string;
76
141
  icon: string;
77
142
  params?: object;
143
+ /** Feature key gating this action; defaults to the owning group's key. */
144
+ feature?: string;
78
145
  }
79
146
 
80
- // HorizontalAlign enum (@univerjs/core): LEFT=1, CENTER=2, RIGHT=3.
81
- // VerticalAlign enum (@univerjs/core): TOP=1, MIDDLE=2, BOTTOM=3.
82
- // WrapStrategy enum (@univerjs/core): OVERFLOW=1, CLIP=2, WRAP=3.
83
- const GROUPS: ToolbarAction[][] = [
84
- [
85
- { id: 'undo', label: 'Undo', command: 'univer.command.undo', icon: 'undo' },
86
- { id: 'redo', label: 'Redo', command: 'univer.command.redo', icon: 'redo' },
87
- ],
88
- [
89
- { id: 'bold', label: 'Bold', command: 'sheet.command.set-range-bold', icon: 'format_bold' },
90
- {
91
- id: 'italic',
92
- label: 'Italic',
93
- command: 'sheet.command.set-range-italic',
94
- icon: 'format_italic',
95
- },
96
- {
97
- id: 'underline',
98
- label: 'Underline',
99
- command: 'sheet.command.set-range-underline',
100
- icon: 'format_underlined',
101
- },
102
- {
103
- id: 'strikethrough',
104
- label: 'Strikethrough',
105
- command: 'sheet.command.set-range-stroke',
106
- icon: 'format_strikethrough',
107
- },
108
- ],
109
- [
110
- {
111
- id: 'align-left',
112
- label: 'Align left',
113
- command: 'sheet.command.set-horizontal-text-align',
114
- icon: 'format_align_left',
115
- params: { value: 1 },
116
- },
117
- {
118
- id: 'align-center',
119
- label: 'Align center',
120
- command: 'sheet.command.set-horizontal-text-align',
121
- icon: 'format_align_center',
122
- params: { value: 2 },
123
- },
124
- {
125
- id: 'align-right',
126
- label: 'Align right',
127
- command: 'sheet.command.set-horizontal-text-align',
128
- icon: 'format_align_right',
129
- params: { value: 3 },
130
- },
131
- ],
132
- [
133
- {
134
- id: 'align-top',
135
- label: 'Align top',
136
- command: 'sheet.command.set-vertical-text-align',
137
- icon: 'vertical_align_top',
138
- params: { value: 1 },
139
- },
140
- {
141
- id: 'align-middle',
142
- label: 'Align middle',
143
- command: 'sheet.command.set-vertical-text-align',
144
- icon: 'vertical_align_center',
145
- params: { value: 2 },
146
- },
147
- {
148
- id: 'align-bottom',
149
- label: 'Align bottom',
150
- command: 'sheet.command.set-vertical-text-align',
151
- icon: 'vertical_align_bottom',
152
- params: { value: 3 },
153
- },
154
- {
155
- id: 'wrap-text',
156
- label: 'Wrap text',
157
- command: 'sheet.command.set-text-wrap',
158
- icon: 'wrap_text',
159
- params: { value: 3 },
160
- },
161
- ],
162
- [
163
- {
164
- id: 'merge',
165
- label: 'Merge cells',
166
- command: 'sheet.command.add-worksheet-merge-all',
167
- icon: 'cell_merge',
168
- },
169
- {
170
- id: 'unmerge',
171
- label: 'Unmerge cells',
172
- command: 'sheet.command.remove-worksheet-merge',
173
- icon: 'splitscreen_vertical_add',
174
- },
175
- ],
176
- [
177
- {
178
- id: 'currency',
179
- label: 'Currency format',
180
- command: 'sheet.command.numfmt.set.currency',
181
- icon: 'attach_money',
182
- },
183
- {
184
- id: 'percent',
185
- label: 'Percent format',
186
- command: 'sheet.command.numfmt.set.percent',
187
- icon: 'percent',
188
- },
189
- {
190
- id: 'decimal-increase',
191
- label: 'Increase decimals',
192
- command: 'sheet.command.numfmt.add.decimal.command',
193
- icon: 'decimal_increase',
194
- },
195
- {
196
- id: 'decimal-decrease',
197
- label: 'Decrease decimals',
198
- command: 'sheet.command.numfmt.subtract.decimal.command',
199
- icon: 'decimal_decrease',
200
- },
201
- ],
202
- [
203
- {
204
- id: 'clear-format',
205
- label: 'Clear formatting',
206
- command: 'sheet.command.clear-selection-format',
207
- icon: 'format_clear',
208
- },
209
- ],
147
+ /**
148
+ * Each entry is a feature-gated group. The `feature` key lets a host hide the
149
+ * whole group (and per-action `feature` keys gate individual buttons). Order +
150
+ * icons mirror the app shell's Home tab.
151
+ *
152
+ * HorizontalAlign enum (@univerjs/core): LEFT=1, CENTER=2, RIGHT=3.
153
+ * VerticalAlign enum (@univerjs/core): TOP=1, MIDDLE=2, BOTTOM=3.
154
+ * WrapStrategy enum (@univerjs/core): OVERFLOW=1, CLIP=2, WRAP=3.
155
+ *
156
+ * Clipboard command ids: `univer.command.{paste,cut,copy}` (matches
157
+ * `home-tab-actions.ts`). Paste values-only uses the base paste command with a
158
+ * predefined hook `value` (`special-paste-value`), same as the app's
159
+ * `pasteSpecial`.
160
+ */
161
+ const GROUPS: { feature: string; actions: ToolbarAction[] }[] = [
162
+ {
163
+ feature: 'history',
164
+ actions: [
165
+ { id: 'undo', label: 'Undo (Ctrl+Z)', command: 'univer.command.undo', icon: 'undo' },
166
+ { id: 'redo', label: 'Redo (Ctrl+Y)', command: 'univer.command.redo', icon: 'redo' },
167
+ ],
168
+ },
169
+ {
170
+ feature: 'clipboard',
171
+ actions: [
172
+ {
173
+ id: 'paste',
174
+ label: 'Paste (Ctrl+V)',
175
+ command: 'univer.command.paste',
176
+ icon: 'content_paste',
177
+ },
178
+ { id: 'cut', label: 'Cut (Ctrl+X)', command: 'univer.command.cut', icon: 'content_cut' },
179
+ { id: 'copy', label: 'Copy (Ctrl+C)', command: 'univer.command.copy', icon: 'content_copy' },
180
+ {
181
+ id: 'paste-values',
182
+ label: 'Paste values only',
183
+ command: 'univer.command.paste',
184
+ icon: 'content_paste_go',
185
+ params: { value: 'special-paste-value' },
186
+ },
187
+ ],
188
+ },
189
+ {
190
+ feature: 'format-painter',
191
+ actions: [
192
+ {
193
+ id: 'format-painter',
194
+ label: 'Format Painter',
195
+ command: 'sheet.command.set-once-format-painter',
196
+ icon: 'format_paint',
197
+ },
198
+ ],
199
+ },
200
+ {
201
+ feature: 'font-style',
202
+ actions: [
203
+ {
204
+ id: 'bold',
205
+ label: 'Bold (Ctrl+B)',
206
+ command: 'sheet.command.set-range-bold',
207
+ icon: 'format_bold',
208
+ },
209
+ {
210
+ id: 'italic',
211
+ label: 'Italic (Ctrl+I)',
212
+ command: 'sheet.command.set-range-italic',
213
+ icon: 'format_italic',
214
+ },
215
+ {
216
+ id: 'underline',
217
+ label: 'Underline (Ctrl+U)',
218
+ command: 'sheet.command.set-range-underline',
219
+ icon: 'format_underlined',
220
+ },
221
+ {
222
+ id: 'strikethrough',
223
+ label: 'Strikethrough',
224
+ command: 'sheet.command.set-range-stroke',
225
+ icon: 'format_strikethrough',
226
+ },
227
+ ],
228
+ },
229
+ {
230
+ feature: 'alignment',
231
+ actions: [
232
+ {
233
+ id: 'align-left',
234
+ label: 'Align left',
235
+ command: 'sheet.command.set-horizontal-text-align',
236
+ icon: 'format_align_left',
237
+ params: { value: 1 },
238
+ },
239
+ {
240
+ id: 'align-center',
241
+ label: 'Align center',
242
+ command: 'sheet.command.set-horizontal-text-align',
243
+ icon: 'format_align_center',
244
+ params: { value: 2 },
245
+ },
246
+ {
247
+ id: 'align-right',
248
+ label: 'Align right',
249
+ command: 'sheet.command.set-horizontal-text-align',
250
+ icon: 'format_align_right',
251
+ params: { value: 3 },
252
+ },
253
+ {
254
+ id: 'wrap-text',
255
+ label: 'Wrap text',
256
+ command: 'sheet.command.set-text-wrap',
257
+ icon: 'wrap_text',
258
+ params: { value: 3 },
259
+ },
260
+ ],
261
+ },
262
+ {
263
+ feature: 'alignment',
264
+ actions: [
265
+ {
266
+ id: 'align-top',
267
+ label: 'Align top',
268
+ command: 'sheet.command.set-vertical-text-align',
269
+ icon: 'vertical_align_top',
270
+ params: { value: 1 },
271
+ },
272
+ {
273
+ id: 'align-middle',
274
+ label: 'Align middle',
275
+ command: 'sheet.command.set-vertical-text-align',
276
+ icon: 'vertical_align_center',
277
+ params: { value: 2 },
278
+ },
279
+ {
280
+ id: 'align-bottom',
281
+ label: 'Align bottom',
282
+ command: 'sheet.command.set-vertical-text-align',
283
+ icon: 'vertical_align_bottom',
284
+ params: { value: 3 },
285
+ },
286
+ ],
287
+ },
288
+ {
289
+ feature: 'merge',
290
+ actions: [
291
+ {
292
+ id: 'merge',
293
+ label: 'Merge cells',
294
+ command: 'sheet.command.add-worksheet-merge-all',
295
+ icon: 'cell_merge',
296
+ },
297
+ {
298
+ id: 'unmerge',
299
+ label: 'Unmerge cells',
300
+ command: 'sheet.command.remove-worksheet-merge',
301
+ icon: 'splitscreen_vertical_add',
302
+ },
303
+ ],
304
+ },
305
+ {
306
+ feature: 'number',
307
+ actions: [
308
+ {
309
+ id: 'currency',
310
+ label: 'Currency format',
311
+ command: 'sheet.command.numfmt.set.currency',
312
+ icon: 'attach_money',
313
+ },
314
+ {
315
+ id: 'percent',
316
+ label: 'Percent format',
317
+ command: 'sheet.command.numfmt.set.percent',
318
+ icon: 'percent',
319
+ },
320
+ {
321
+ id: 'decimal-increase',
322
+ label: 'Increase decimals',
323
+ command: 'sheet.command.numfmt.add.decimal.command',
324
+ icon: 'decimal_increase',
325
+ },
326
+ {
327
+ id: 'decimal-decrease',
328
+ label: 'Decrease decimals',
329
+ command: 'sheet.command.numfmt.subtract.decimal.command',
330
+ icon: 'decimal_decrease',
331
+ },
332
+ ],
333
+ },
334
+ {
335
+ feature: 'clear-format',
336
+ actions: [
337
+ {
338
+ id: 'clear-format',
339
+ label: 'Clear formatting',
340
+ command: 'sheet.command.clear-selection-format',
341
+ icon: 'format_clear',
342
+ },
343
+ ],
344
+ },
210
345
  ];
211
346
 
212
347
  const BAR_STYLE: CSSProperties = {
@@ -218,6 +353,7 @@ const BAR_STYLE: CSSProperties = {
218
353
  background: 'var(--cs-chrome-bg, #eef1f5)',
219
354
  flex: '0 0 auto',
220
355
  userSelect: 'none',
356
+ flexWrap: 'wrap',
221
357
  };
222
358
 
223
359
  const BTN_STYLE: CSSProperties = {
@@ -257,6 +393,25 @@ const SELECT_STYLE: CSSProperties = {
257
393
  export interface ToolbarProps {
258
394
  /** Live API, or `null` until the editor is ready. */
259
395
  api: CasualSheetsAPI | null;
396
+ /**
397
+ * Per-control / per-group feature flags. A key set to `false` hides that
398
+ * control or group; omitted keys default to enabled. Keys:
399
+ * history · clipboard · format-painter · font · font-style · color ·
400
+ * borders · alignment · merge · number · autosum · clear-format ·
401
+ * format-cells · insert-chart · pivot-table
402
+ */
403
+ features?: Record<string, boolean>;
404
+ /**
405
+ * Host hook for dialogs the SDK has no built-in UI for (Format Cells, Insert
406
+ * Chart, PivotTable). When provided, those controls render and call this with
407
+ * a `kind`; when omitted, they are not rendered (no fake dialog).
408
+ */
409
+ onDialogRequest?: (kind: string, context?: unknown) => void;
410
+ }
411
+
412
+ /** A feature is on unless explicitly set to `false`. */
413
+ function enabled(features: Record<string, boolean> | undefined, key: string): boolean {
414
+ return features?.[key] !== false;
260
415
  }
261
416
 
262
417
  // Which actions reflect an active state, keyed off the active cell's style.
@@ -289,7 +444,7 @@ function isActive(id: string, s: ActiveStyle): boolean {
289
444
  }
290
445
  }
291
446
 
292
- export function Toolbar({ api }: ToolbarProps) {
447
+ export function Toolbar({ api, features, onDialogRequest }: ToolbarProps) {
293
448
  const [active, setActive] = useState<ActiveStyle>(NO_STYLE);
294
449
 
295
450
  useEffect(() => {
@@ -313,89 +468,264 @@ export function Toolbar({ api }: ToolbarProps) {
313
468
 
314
469
  const dispatch = (command: string, params?: object) => void api?.executeCommand(command, params);
315
470
 
471
+ /**
472
+ * Apply a font size via the dedicated command (matches the app's
473
+ * `set-range-fontsize`). Used by the size dropdown + grow/shrink.
474
+ */
475
+ const applyFontSize = (size: number) => {
476
+ if (!Number.isFinite(size) || size <= 0) return;
477
+ dispatch('sheet.command.set-range-fontsize', { value: size });
478
+ };
479
+
480
+ /**
481
+ * Grow / shrink the active cell's font size by `delta`, mirroring the app's
482
+ * `adjustFontSize` (read current off the active cell, fall back to 11, clamp
483
+ * to [6, 72]). Reads the size we already track in `active`.
484
+ */
485
+ const adjustFontSize = (delta: number) => {
486
+ const current = active.fs && active.fs > 0 ? active.fs : 11;
487
+ const next = Math.max(6, Math.min(72, current + delta));
488
+ if (next === current) return;
489
+ applyFontSize(next);
490
+ };
491
+
492
+ /**
493
+ * Apply a number-format pattern via the FUniver facade — `FRange.setNumberFormat`
494
+ * is a runtime numfmt-facade extension (added via `FUniver.extend()`), so a
495
+ * runtime cast is the cleanest way to reach it. Same approach as the app's
496
+ * `home-tab-actions.setNumberFormat`.
497
+ */
498
+ const applyNumberFormat = (pattern: string) => {
499
+ const sheet = api?.univer.getActiveWorkbook()?.getActiveSheet();
500
+ const range = (sheet as unknown as { getActiveRange?: () => unknown })?.getActiveRange?.() as
501
+ | { setNumberFormat?: (p: string) => unknown }
502
+ | undefined;
503
+ range?.setNumberFormat?.(pattern);
504
+ };
505
+
316
506
  // Reflect current font in the dropdowns; surface a non-listed value too.
317
- const familyValue = active.ff || 'Arial';
507
+ const familyValue = active.ff || 'Calibri';
318
508
  const familyOptions =
319
509
  active.ff && !FONT_FAMILIES.includes(active.ff) ? [active.ff, ...FONT_FAMILIES] : FONT_FAMILIES;
320
510
  const sizeValue = active.fs || 11;
321
511
  const sizeOptions =
322
512
  active.fs && !FONT_SIZES.includes(active.fs) ? [active.fs, ...FONT_SIZES] : FONT_SIZES;
513
+ const numberFormatValue = detectFormatKey(active.nf);
514
+
515
+ const renderActionButton = (a: ToolbarAction) => {
516
+ const on = isActive(a.id, active);
517
+ const baseBg = on ? 'var(--cs-chrome-active, #e6f3f7)' : 'transparent';
518
+ return (
519
+ <button
520
+ key={a.id}
521
+ type="button"
522
+ title={a.label}
523
+ aria-label={a.label}
524
+ aria-pressed={on}
525
+ data-action={a.id}
526
+ data-testid={`cs-${a.id}`}
527
+ data-active={on ? 'true' : undefined}
528
+ disabled={!api}
529
+ style={{
530
+ ...BTN_STYLE,
531
+ background: baseBg,
532
+ color: on ? 'var(--cs-chrome-active-fg, #0e7490)' : BTN_STYLE.color,
533
+ }}
534
+ // mousedown (not click) so the grid's selection isn't lost first.
535
+ onMouseDown={(e) => {
536
+ e.preventDefault();
537
+ dispatch(a.command, a.params);
538
+ }}
539
+ onMouseEnter={(e) => {
540
+ if (!on) e.currentTarget.style.background = 'var(--cs-chrome-hover, rgba(0,0,0,0.06))';
541
+ }}
542
+ onMouseLeave={(e) => {
543
+ e.currentTarget.style.background = baseBg;
544
+ }}
545
+ >
546
+ <Icon name={a.icon} size={20} />
547
+ </button>
548
+ );
549
+ };
550
+
551
+ // A plain (non-command) icon button — used for grow/shrink + dialog requests.
552
+ const renderIconButton = (
553
+ id: string,
554
+ label: string,
555
+ icon: string,
556
+ onPress: () => void,
557
+ testid: string,
558
+ ) => (
559
+ <button
560
+ key={id}
561
+ type="button"
562
+ title={label}
563
+ aria-label={label}
564
+ data-action={id}
565
+ data-testid={testid}
566
+ disabled={!api}
567
+ style={BTN_STYLE}
568
+ onMouseDown={(e) => {
569
+ e.preventDefault();
570
+ onPress();
571
+ }}
572
+ onMouseEnter={(e) => {
573
+ e.currentTarget.style.background = 'var(--cs-chrome-hover, rgba(0,0,0,0.06))';
574
+ }}
575
+ onMouseLeave={(e) => {
576
+ e.currentTarget.style.background = 'transparent';
577
+ }}
578
+ >
579
+ <Icon name={icon} size={20} />
580
+ </button>
581
+ );
582
+
583
+ // Render the static command groups, skipping any whose feature is off and any
584
+ // group left empty after per-action filtering. A leading divider separates a
585
+ // group from whatever rendered before it.
586
+ let firstGroupRendered = false;
587
+ const groupNodes = GROUPS.map((group, gi) => {
588
+ if (!enabled(features, group.feature)) return null;
589
+ const visible = group.actions.filter((a) => enabled(features, a.feature ?? group.feature));
590
+ if (visible.length === 0) return null;
591
+ const showDivider = firstGroupRendered;
592
+ firstGroupRendered = true;
593
+ return (
594
+ <span key={gi} style={{ display: 'inline-flex', alignItems: 'center' }}>
595
+ {showDivider && <span style={DIVIDER_STYLE} aria-hidden />}
596
+ {visible.map(renderActionButton)}
597
+ </span>
598
+ );
599
+ });
600
+
601
+ const showFont = enabled(features, 'font');
602
+ const showColor = enabled(features, 'color');
603
+ const showBorders = enabled(features, 'borders');
604
+ const showAutoSum = enabled(features, 'autosum');
605
+ const showNumberDropdown = enabled(features, 'number');
606
+
607
+ // Dialog-only controls: render ONLY when the host gave us a way to open them.
608
+ const showFormatCells = enabled(features, 'format-cells') && !!onDialogRequest;
609
+ const showInsertChart = enabled(features, 'insert-chart') && !!onDialogRequest;
610
+ const showPivotTable = enabled(features, 'pivot-table') && !!onDialogRequest;
611
+ const showDialogGroup = showFormatCells || showInsertChart || showPivotTable;
323
612
 
324
613
  return (
325
614
  <div style={BAR_STYLE} data-testid="casual-sheets-toolbar" role="toolbar" aria-label="Editor">
326
- <select
327
- aria-label="Font family"
328
- data-testid="cs-font-family"
329
- style={{ ...SELECT_STYLE, width: 116 }}
330
- value={familyValue}
331
- onChange={(e) => dispatch('sheet.command.set-range-font-family', { value: e.target.value })}
332
- >
333
- {familyOptions.map((f) => (
334
- <option key={f} value={f}>
335
- {f}
336
- </option>
337
- ))}
338
- </select>
339
- <select
340
- aria-label="Font size"
341
- data-testid="cs-font-size"
342
- style={{ ...SELECT_STYLE, width: 56, marginLeft: 4 }}
343
- value={sizeValue}
344
- onChange={(e) =>
345
- dispatch('sheet.command.set-range-fontsize', { value: Number(e.target.value) })
346
- }
347
- >
348
- {sizeOptions.map((s) => (
349
- <option key={s} value={s}>
350
- {s}
351
- </option>
352
- ))}
353
- </select>
354
- <span style={DIVIDER_STYLE} aria-hidden />
355
- {GROUPS.map((group, gi) => (
356
- <span key={gi} style={{ display: 'inline-flex', alignItems: 'center' }}>
357
- {gi > 0 && <span style={DIVIDER_STYLE} aria-hidden />}
358
- {group.map((a) => {
359
- const on = isActive(a.id, active);
360
- const baseBg = on ? 'var(--cs-chrome-active, #e6f3f7)' : 'transparent';
361
- return (
362
- <button
363
- key={a.id}
364
- type="button"
365
- title={a.label}
366
- aria-label={a.label}
367
- aria-pressed={on}
368
- data-action={a.id}
369
- data-active={on ? 'true' : undefined}
370
- style={{
371
- ...BTN_STYLE,
372
- background: baseBg,
373
- color: on ? 'var(--cs-chrome-active-fg, #0e7490)' : BTN_STYLE.color,
374
- }}
375
- // mousedown (not click) so the grid's selection isn't lost first.
376
- onMouseDown={(e) => {
377
- e.preventDefault();
378
- dispatch(a.command, a.params);
379
- }}
380
- onMouseEnter={(e) => {
381
- if (!on)
382
- e.currentTarget.style.background = 'var(--cs-chrome-hover, rgba(0,0,0,0.06))';
383
- }}
384
- onMouseLeave={(e) => {
385
- e.currentTarget.style.background = baseBg;
386
- }}
387
- >
388
- <Icon name={a.icon} size={20} />
389
- </button>
390
- );
391
- })}
392
- </span>
393
- ))}
394
- <span style={DIVIDER_STYLE} aria-hidden />
395
- <ColorPicker api={api} />
396
- <BordersPicker api={api} />
397
- <span style={DIVIDER_STYLE} aria-hidden />
398
- <AutoSumPicker api={api} />
615
+ {showFont && (
616
+ <>
617
+ <select
618
+ aria-label="Font family"
619
+ data-testid="cs-font-family"
620
+ style={{ ...SELECT_STYLE, width: 130 }}
621
+ value={familyValue}
622
+ disabled={!api}
623
+ onChange={(e) =>
624
+ dispatch('sheet.command.set-range-font-family', { value: e.target.value })
625
+ }
626
+ >
627
+ {familyOptions.map((f) => (
628
+ <option key={f} value={f}>
629
+ {f}
630
+ </option>
631
+ ))}
632
+ </select>
633
+ <select
634
+ aria-label="Font size"
635
+ data-testid="cs-font-size"
636
+ style={{ ...SELECT_STYLE, width: 56, marginLeft: 4 }}
637
+ value={sizeValue}
638
+ disabled={!api}
639
+ onChange={(e) => applyFontSize(Number(e.target.value))}
640
+ >
641
+ {sizeOptions.map((s) => (
642
+ <option key={s} value={s}>
643
+ {s}
644
+ </option>
645
+ ))}
646
+ </select>
647
+ {renderIconButton(
648
+ 'font-size-up',
649
+ 'Increase font size',
650
+ 'text_increase',
651
+ () => adjustFontSize(+1),
652
+ 'cs-font-size-up',
653
+ )}
654
+ {renderIconButton(
655
+ 'font-size-down',
656
+ 'Decrease font size',
657
+ 'text_decrease',
658
+ () => adjustFontSize(-1),
659
+ 'cs-font-size-down',
660
+ )}
661
+ <span style={DIVIDER_STYLE} aria-hidden />
662
+ </>
663
+ )}
664
+
665
+ {groupNodes}
666
+
667
+ {(showColor || showBorders) && <span style={DIVIDER_STYLE} aria-hidden />}
668
+ {showColor && <ColorPicker api={api} />}
669
+ {showBorders && <BordersPicker api={api} />}
670
+
671
+ {showNumberDropdown && (
672
+ <>
673
+ <span style={DIVIDER_STYLE} aria-hidden />
674
+ <select
675
+ aria-label="Number format"
676
+ data-testid="cs-number-format"
677
+ style={{ ...SELECT_STYLE, width: 124 }}
678
+ value={numberFormatValue}
679
+ disabled={!api}
680
+ onChange={(e) =>
681
+ applyNumberFormat(NUMBER_FORMAT_PATTERNS[e.target.value as NumberFormatKey])
682
+ }
683
+ >
684
+ {NUMBER_FORMAT_OPTIONS.map((o) => (
685
+ <option key={o.value} value={o.value}>
686
+ {o.label}
687
+ </option>
688
+ ))}
689
+ </select>
690
+ </>
691
+ )}
692
+
693
+ {showAutoSum && (
694
+ <>
695
+ <span style={DIVIDER_STYLE} aria-hidden />
696
+ <AutoSumPicker api={api} />
697
+ </>
698
+ )}
699
+
700
+ {showDialogGroup && (
701
+ <>
702
+ <span style={DIVIDER_STYLE} aria-hidden />
703
+ {showFormatCells &&
704
+ renderIconButton(
705
+ 'format-cells',
706
+ 'Format Cells…',
707
+ 'format_shapes',
708
+ () => onDialogRequest?.('format-cells'),
709
+ 'cs-format-cells',
710
+ )}
711
+ {showInsertChart &&
712
+ renderIconButton(
713
+ 'insert-chart',
714
+ 'Insert chart',
715
+ 'bar_chart',
716
+ () => onDialogRequest?.('insert-chart'),
717
+ 'cs-insert-chart',
718
+ )}
719
+ {showPivotTable &&
720
+ renderIconButton(
721
+ 'pivot-table',
722
+ 'Insert PivotTable',
723
+ 'pivot_table_chart',
724
+ () => onDialogRequest?.('pivot-table'),
725
+ 'cs-pivot-table',
726
+ )}
727
+ </>
728
+ )}
399
729
  </div>
400
730
  );
401
731
  }