@casualoffice/sheets 0.9.0 → 0.10.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.
Files changed (67) hide show
  1. package/dist/api-BI2VLYQ6.d.cts +62 -0
  2. package/dist/api-BI2VLYQ6.d.ts +62 -0
  3. package/dist/chrome.cjs +2569 -0
  4. package/dist/chrome.cjs.map +1 -0
  5. package/dist/chrome.d.cts +96 -0
  6. package/dist/chrome.d.ts +96 -0
  7. package/dist/chrome.js +2556 -0
  8. package/dist/chrome.js.map +1 -0
  9. package/dist/collab.cjs +770 -0
  10. package/dist/collab.cjs.map +1 -0
  11. package/dist/collab.d.cts +248 -0
  12. package/dist/collab.d.ts +248 -0
  13. package/dist/collab.js +737 -0
  14. package/dist/collab.js.map +1 -0
  15. package/dist/embed/embed-runtime.js +128 -128
  16. package/dist/embed.cjs +166 -0
  17. package/dist/embed.cjs.map +1 -1
  18. package/dist/embed.d.cts +78 -3
  19. package/dist/embed.d.ts +78 -3
  20. package/dist/embed.js +166 -0
  21. package/dist/embed.js.map +1 -1
  22. package/dist/index.cjs +262 -165
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.d.cts +4 -3
  25. package/dist/index.d.ts +4 -3
  26. package/dist/index.js +271 -168
  27. package/dist/index.js.map +1 -1
  28. package/dist/{protocol--KyBQUjU.d.cts → protocol-Cq4Cdoyi.d.cts} +19 -2
  29. package/dist/{protocol-cEzy7S0i.d.ts → protocol-DqDaG2yG.d.ts} +19 -2
  30. package/dist/sheets.cjs +102 -16
  31. package/dist/sheets.cjs.map +1 -1
  32. package/dist/sheets.d.cts +49 -63
  33. package/dist/sheets.d.ts +49 -63
  34. package/dist/sheets.js +111 -19
  35. package/dist/sheets.js.map +1 -1
  36. package/package.json +28 -3
  37. package/src/chrome/AutoSumPicker.tsx +176 -0
  38. package/src/chrome/BordersPicker.tsx +171 -0
  39. package/src/chrome/ChromeBottom.tsx +18 -0
  40. package/src/chrome/ChromeTop.tsx +21 -0
  41. package/src/chrome/ColorPicker.tsx +220 -0
  42. package/src/chrome/FindReplace.tsx +370 -0
  43. package/src/chrome/FormulaBar.tsx +378 -0
  44. package/src/chrome/Icon.tsx +43 -0
  45. package/src/chrome/MenuBar.tsx +336 -0
  46. package/src/chrome/NameBox.tsx +347 -0
  47. package/src/chrome/SheetTabs.tsx +346 -0
  48. package/src/chrome/StatusBar.tsx +232 -0
  49. package/src/chrome/Toolbar.tsx +401 -0
  50. package/src/chrome/fonts.ts +42 -0
  51. package/src/chrome/index.ts +24 -0
  52. package/src/collab/attachCollab.ts +151 -0
  53. package/src/collab/bridge-helpers.ts +97 -0
  54. package/src/collab/bridge.ts +885 -0
  55. package/src/collab/bridge.unit.test.ts +160 -0
  56. package/src/collab/index.ts +38 -0
  57. package/src/collab/replay-retry.ts +137 -0
  58. package/src/collab/replay-retry.unit.test.ts +223 -0
  59. package/src/collab/ws-url.ts +20 -0
  60. package/src/collab/ws-url.unit.test.ts +35 -0
  61. package/src/embed/EmbedHostTransport.ts +16 -1
  62. package/src/embed/EmbedTransport.ts +16 -0
  63. package/src/embed/EmbedTransport.unit.test.ts +88 -2
  64. package/src/embed/index.ts +7 -0
  65. package/src/embed/protocol.ts +34 -0
  66. package/src/embed-runtime/index.tsx +20 -0
  67. package/src/sheets/CasualSheets.tsx +204 -33
@@ -0,0 +1,346 @@
1
+ /**
2
+ * SheetTabs — worksheet tab strip for `<CasualSheets chrome>`.
3
+ *
4
+ * The fundamental multi-sheet control the built-in chrome was missing: switch
5
+ * sheets (click), add a sheet (+), rename (double-click), and delete (context
6
+ * menu). Drives the editor through the FUniver facade only — `insertSheet` /
7
+ * `setActiveSheet` / `deleteSheet` / `FWorksheet.setName` — and stays live by
8
+ * subscribing to the sheet-lifecycle facade events (plus the mutation-level
9
+ * fallback so collab/replay-driven changes refresh too).
10
+ *
11
+ * Scope of this batch: switch / add / rename / delete. Drag-reorder, tab
12
+ * colour, hide/unhide, and duplicate are richer host features deferred to
13
+ * later parity batches (the reference app's SheetTabs has them).
14
+ */
15
+
16
+ import { useEffect, useState, type CSSProperties, type KeyboardEvent } from 'react';
17
+ import type { CasualSheetsAPI } from '../sheets/api';
18
+
19
+ interface SheetTab {
20
+ id: string;
21
+ name: string;
22
+ hidden: boolean;
23
+ }
24
+
25
+ interface SheetsSnapshot {
26
+ tabs: SheetTab[];
27
+ activeId: string | null;
28
+ }
29
+
30
+ const EMPTY: SheetsSnapshot = { tabs: [], activeId: null };
31
+
32
+ /** Sheet-list mutations that must refresh the strip even when they arrive via
33
+ * the collab bridge's mutation-only replay (which bypasses facade events). */
34
+ const SHEET_LIST_MUTATIONS = new Set([
35
+ 'sheet.mutation.insert-sheet',
36
+ 'sheet.mutation.remove-sheet',
37
+ 'sheet.mutation.set-worksheet-name',
38
+ 'sheet.mutation.set-worksheet-order',
39
+ 'sheet.mutation.set-worksheet-hidden',
40
+ ]);
41
+
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ type AnyFacade = any;
44
+
45
+ function readSheets(api: CasualSheetsAPI): SheetsSnapshot {
46
+ const wb = api.univer.getActiveWorkbook();
47
+ if (!wb) return EMPTY;
48
+ const tabs = wb.getSheets().map((s) => ({
49
+ id: s.getSheetId(),
50
+ name: s.getSheetName(),
51
+ hidden: (s as AnyFacade).isSheetHidden?.() === true,
52
+ }));
53
+ return { tabs, activeId: wb.getActiveSheet()?.getSheetId() ?? null };
54
+ }
55
+
56
+ const BAR_STYLE: CSSProperties = {
57
+ display: 'flex',
58
+ alignItems: 'stretch',
59
+ gap: 2,
60
+ height: 30,
61
+ flex: '0 0 auto',
62
+ padding: '0 6px',
63
+ borderTop: '1px solid var(--cs-chrome-border, rgba(0,0,0,0.12))',
64
+ background: 'var(--cs-chrome-bg, #f8f9fa)',
65
+ font: 'inherit',
66
+ fontSize: 13,
67
+ overflowX: 'auto',
68
+ overflowY: 'hidden',
69
+ };
70
+
71
+ const ADD_BTN_STYLE: CSSProperties = {
72
+ flex: '0 0 auto',
73
+ width: 28,
74
+ border: 'none',
75
+ background: 'transparent',
76
+ color: 'var(--cs-chrome-fg, #201f1e)',
77
+ cursor: 'pointer',
78
+ fontSize: 18,
79
+ lineHeight: 1,
80
+ borderRadius: 4,
81
+ };
82
+
83
+ const RENAME_INPUT_STYLE: CSSProperties = {
84
+ height: 22,
85
+ alignSelf: 'center',
86
+ padding: '0 6px',
87
+ border: '1px solid var(--cs-chrome-active-fg, #0e7490)',
88
+ borderRadius: 4,
89
+ background: 'var(--cs-chrome-input-bg, #fff)',
90
+ color: 'var(--cs-chrome-fg, #201f1e)',
91
+ font: 'inherit',
92
+ fontSize: 13,
93
+ width: 120,
94
+ };
95
+
96
+ const MENU_STYLE: CSSProperties = {
97
+ position: 'fixed',
98
+ zIndex: 1000,
99
+ minWidth: 140,
100
+ padding: 4,
101
+ border: '1px solid var(--cs-chrome-border, #e6e9ee)',
102
+ borderRadius: 8,
103
+ background: 'var(--cs-chrome-input-bg, #fff)',
104
+ boxShadow: '0 6px 20px rgba(0,0,0,0.16)',
105
+ };
106
+
107
+ const MENU_ITEM_STYLE: CSSProperties = {
108
+ display: 'block',
109
+ width: '100%',
110
+ textAlign: 'left',
111
+ height: 28,
112
+ padding: '0 10px',
113
+ border: 'none',
114
+ borderRadius: 6,
115
+ background: 'transparent',
116
+ color: 'var(--cs-chrome-fg, #201f1e)',
117
+ font: 'inherit',
118
+ fontSize: 13,
119
+ cursor: 'pointer',
120
+ };
121
+
122
+ export interface SheetTabsProps {
123
+ /** Live API, or `null` until the editor is ready. */
124
+ api: CasualSheetsAPI | null;
125
+ }
126
+
127
+ export function SheetTabs({ api }: SheetTabsProps) {
128
+ const [{ tabs, activeId }, setSnapshot] = useState<SheetsSnapshot>(EMPTY);
129
+ // Inline-rename target sheet id (null = not renaming) + its draft text.
130
+ const [renaming, setRenaming] = useState<string | null>(null);
131
+ const [draft, setDraft] = useState('');
132
+ // Right-click context menu: anchor + target sheet id.
133
+ const [menu, setMenu] = useState<{ x: number; y: number; sheetId: string } | null>(null);
134
+
135
+ useEffect(() => {
136
+ if (!api) return;
137
+ const refresh = () => setSnapshot(readSheets(api));
138
+ refresh();
139
+ const f = api.univer as AnyFacade;
140
+ const subs: Array<{ dispose(): void }> = [];
141
+ try {
142
+ subs.push(
143
+ f.addEvent(f.Event.SheetCreated, refresh),
144
+ f.addEvent(f.Event.SheetDeleted, refresh),
145
+ f.addEvent(f.Event.SheetNameChanged, refresh),
146
+ f.addEvent(f.Event.ActiveSheetChanged, refresh),
147
+ f.addEvent(f.Event.SheetMoved, refresh),
148
+ // Mutation-level fallback — catches collab/replay-driven changes that
149
+ // don't emit the higher-level facade events.
150
+ f.addEvent(f.Event.CommandExecuted, (e: { id?: string }) => {
151
+ if (e?.id && SHEET_LIST_MUTATIONS.has(e.id)) refresh();
152
+ }),
153
+ );
154
+ // Whole-unit swaps (loadSnapshot / File→Open) don't fire SheetCreated;
155
+ // refresh a tick after the new unit is wired in.
156
+ if (typeof f.onUniverSheetCreated === 'function') {
157
+ subs.push(f.onUniverSheetCreated(() => queueMicrotask(refresh)));
158
+ }
159
+ } catch {
160
+ /* facade event surface differs — the initial read still populated tabs */
161
+ }
162
+ return () => {
163
+ for (const s of subs) s?.dispose?.();
164
+ };
165
+ }, [api]);
166
+
167
+ // Close the context menu on any outside interaction.
168
+ useEffect(() => {
169
+ if (!menu) return;
170
+ const close = () => setMenu(null);
171
+ window.addEventListener('pointerdown', close);
172
+ window.addEventListener('keydown', close);
173
+ return () => {
174
+ window.removeEventListener('pointerdown', close);
175
+ window.removeEventListener('keydown', close);
176
+ };
177
+ }, [menu]);
178
+
179
+ if (!api) {
180
+ return <div style={BAR_STYLE} data-testid="casual-sheets-tabs" />;
181
+ }
182
+
183
+ const visible = tabs.filter((t) => !t.hidden);
184
+ const wb = () => api.univer.getActiveWorkbook();
185
+
186
+ const switchTo = (id: string) => {
187
+ if (id === activeId) return;
188
+ wb()?.setActiveSheet(id);
189
+ };
190
+
191
+ const addSheet = () => {
192
+ const created = (wb() as AnyFacade)?.insertSheet?.();
193
+ created?.activate?.();
194
+ };
195
+
196
+ const startRename = (tab: SheetTab) => {
197
+ setMenu(null);
198
+ setRenaming(tab.id);
199
+ setDraft(tab.name);
200
+ };
201
+
202
+ const commitRename = () => {
203
+ const id = renaming;
204
+ setRenaming(null);
205
+ if (!id) return;
206
+ const name = draft.trim();
207
+ if (!name) return;
208
+ const sheet = (wb() as AnyFacade)?.getSheetBySheetId?.(id);
209
+ if (sheet && sheet.getSheetName?.() !== name) sheet.setName?.(name);
210
+ };
211
+
212
+ const deleteSheet = (id: string) => {
213
+ setMenu(null);
214
+ // Excel keeps at least one visible sheet — never delete the last one.
215
+ if (visible.length <= 1) return;
216
+ (wb() as AnyFacade)?.deleteSheet?.(id);
217
+ };
218
+
219
+ const onRenameKey = (e: KeyboardEvent<HTMLInputElement>) => {
220
+ if (e.key === 'Enter') {
221
+ e.preventDefault();
222
+ commitRename();
223
+ } else if (e.key === 'Escape') {
224
+ e.preventDefault();
225
+ setRenaming(null);
226
+ }
227
+ };
228
+
229
+ return (
230
+ <div style={BAR_STYLE} data-testid="casual-sheets-tabs" role="tablist">
231
+ {visible.map((tab) => {
232
+ const active = tab.id === activeId;
233
+ if (renaming === tab.id) {
234
+ return (
235
+ <input
236
+ key={tab.id}
237
+ autoFocus
238
+ style={RENAME_INPUT_STYLE}
239
+ value={draft}
240
+ data-testid="cs-tab-rename-input"
241
+ onChange={(e) => setDraft(e.target.value)}
242
+ onKeyDown={onRenameKey}
243
+ onBlur={commitRename}
244
+ />
245
+ );
246
+ }
247
+ return (
248
+ <button
249
+ key={tab.id}
250
+ type="button"
251
+ role="tab"
252
+ aria-selected={active}
253
+ data-testid={`cs-tab-${tab.id}`}
254
+ data-active={active || undefined}
255
+ title={tab.name}
256
+ style={{
257
+ flex: '0 0 auto',
258
+ maxWidth: 160,
259
+ padding: '0 12px',
260
+ border: 'none',
261
+ borderTop: active
262
+ ? '2px solid var(--cs-chrome-active-fg, #0e7490)'
263
+ : '2px solid transparent',
264
+ background: active ? 'var(--cs-chrome-input-bg, #fff)' : 'transparent',
265
+ color: active
266
+ ? 'var(--cs-chrome-active-fg, #0e7490)'
267
+ : 'var(--cs-chrome-fg, #201f1e)',
268
+ fontWeight: active ? 600 : 400,
269
+ font: 'inherit',
270
+ fontSize: 13,
271
+ cursor: 'pointer',
272
+ whiteSpace: 'nowrap',
273
+ overflow: 'hidden',
274
+ textOverflow: 'ellipsis',
275
+ }}
276
+ // mousedown keeps the grid from stealing focus mid-switch.
277
+ onMouseDown={(e) => {
278
+ e.preventDefault();
279
+ switchTo(tab.id);
280
+ }}
281
+ onDoubleClick={() => startRename(tab)}
282
+ onContextMenu={(e) => {
283
+ e.preventDefault();
284
+ setMenu({ x: e.clientX, y: e.clientY, sheetId: tab.id });
285
+ }}
286
+ >
287
+ {tab.name}
288
+ </button>
289
+ );
290
+ })}
291
+ <button
292
+ type="button"
293
+ style={ADD_BTN_STYLE}
294
+ aria-label="Add sheet"
295
+ title="Add sheet"
296
+ data-testid="cs-tab-add"
297
+ onMouseDown={(e) => {
298
+ e.preventDefault();
299
+ addSheet();
300
+ }}
301
+ >
302
+ +
303
+ </button>
304
+
305
+ {menu && (
306
+ <div
307
+ style={{ ...MENU_STYLE, left: menu.x, top: menu.y }}
308
+ data-testid="cs-tab-menu"
309
+ role="menu"
310
+ // Keep clicks inside the menu from closing it via the window handler.
311
+ onPointerDown={(e) => e.stopPropagation()}
312
+ >
313
+ <button
314
+ type="button"
315
+ role="menuitem"
316
+ style={MENU_ITEM_STYLE}
317
+ data-testid="cs-tab-menu-rename"
318
+ onMouseDown={(e) => {
319
+ e.preventDefault();
320
+ const tab = tabs.find((t) => t.id === menu.sheetId);
321
+ if (tab) startRename(tab);
322
+ }}
323
+ >
324
+ Rename
325
+ </button>
326
+ <button
327
+ type="button"
328
+ role="menuitem"
329
+ style={{
330
+ ...MENU_ITEM_STYLE,
331
+ opacity: visible.length <= 1 ? 0.5 : 1,
332
+ cursor: visible.length <= 1 ? 'not-allowed' : 'pointer',
333
+ }}
334
+ data-testid="cs-tab-menu-delete"
335
+ onMouseDown={(e) => {
336
+ e.preventDefault();
337
+ deleteSheet(menu.sheetId);
338
+ }}
339
+ >
340
+ Delete
341
+ </button>
342
+ </div>
343
+ )}
344
+ </div>
345
+ );
346
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * StatusBar — built-in status bar for `<CasualSheets chrome>`.
3
+ *
4
+ * Self-contained: reads the active selection through `CasualSheetsAPI` and shows
5
+ * Excel-style aggregates over it (Average / Count / Numerical Count / Min / Max
6
+ * / Sum), plus a zoom control on the right (− / level / +, click the level to
7
+ * reset to 100%). Drives the editor only through the facade + commands.
8
+ *
9
+ * Count = non-empty cells (any type); Numerical Count = numeric cells; the
10
+ * numeric aggregates (Average/Min/Max/Sum) run over the numeric cells only —
11
+ * matching Excel's status-bar semantics.
12
+ *
13
+ * Zoom dispatches the OPERATION `sheet.operation.set-zoom-ratio` (not the
14
+ * `set-zoom-ratio` / `change-zoom-ratio` commands, which bail when Univer's
15
+ * always-present formula-bar editor unit reports visible). The operation's
16
+ * render controller registers in sheets-ui's `onRendered` lifecycle — i.e. a
17
+ * frame or two after `onReady` — so the buttons just no-op until the grid has
18
+ * painted; in practice the user can't click that fast.
19
+ */
20
+
21
+ import { useEffect, useState, type CSSProperties } from 'react';
22
+ import { ICommandService } from '@univerjs/core';
23
+ import type { CasualSheetsAPI } from '../sheets/api';
24
+
25
+ interface Stats {
26
+ /** Non-empty cells (any type). */
27
+ count: number;
28
+ /** Numeric cells. */
29
+ numCount: number;
30
+ sum: number;
31
+ avg: number;
32
+ min: number;
33
+ max: number;
34
+ }
35
+
36
+ function readStats(api: CasualSheetsAPI): Stats | null {
37
+ const sel = api.getSelection();
38
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
39
+ if (!sel || !sheet) return null;
40
+ const { startRow, startColumn, endRow, endColumn } = sel.range;
41
+ // Single cell → nothing to aggregate (matches Excel).
42
+ if (startRow === endRow && startColumn === endColumn) return null;
43
+ const values = sheet.getRange(sel.range).getValues?.() as unknown[][] | undefined;
44
+ if (!values) return null;
45
+ let count = 0;
46
+ let numCount = 0;
47
+ let sum = 0;
48
+ let min = Infinity;
49
+ let max = -Infinity;
50
+ for (const row of values) {
51
+ for (const v of row) {
52
+ if (v == null || v === '') continue;
53
+ count += 1;
54
+ if (typeof v === 'number' && Number.isFinite(v)) {
55
+ numCount += 1;
56
+ sum += v;
57
+ if (v < min) min = v;
58
+ if (v > max) max = v;
59
+ }
60
+ }
61
+ }
62
+ if (count === 0) return null;
63
+ return { count, numCount, sum, avg: numCount ? sum / numCount : 0, min, max };
64
+ }
65
+
66
+ /** Current zoom as a fraction (1 = 100%). Reads the live worksheet config via
67
+ * the facade's `getSheet()` escape hatch; falls back to 1. */
68
+ function readZoom(api: CasualSheetsAPI): number {
69
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ const ws = (sheet as any)?.getSheet?.();
72
+ const z = ws?.getConfig?.().zoomRatio;
73
+ return typeof z === 'number' && z > 0 ? z : 1;
74
+ }
75
+
76
+ // Trim float noise without locking to a fixed precision.
77
+ function fmt(n: number): string {
78
+ return Number(n.toFixed(10)).toLocaleString();
79
+ }
80
+
81
+ const BAR_STYLE: CSSProperties = {
82
+ display: 'flex',
83
+ alignItems: 'center',
84
+ justifyContent: 'flex-end',
85
+ gap: 16,
86
+ height: 24,
87
+ padding: '0 12px',
88
+ borderTop: '1px solid var(--cs-chrome-border, rgba(0,0,0,0.12))',
89
+ background: 'var(--cs-chrome-bg, #f8f9fa)',
90
+ color: 'var(--cs-chrome-muted, #4b5563)',
91
+ flex: '0 0 auto',
92
+ font: 'inherit',
93
+ fontSize: 12,
94
+ userSelect: 'none',
95
+ };
96
+
97
+ const ZOOM_GROUP_STYLE: CSSProperties = {
98
+ display: 'flex',
99
+ alignItems: 'center',
100
+ gap: 4,
101
+ };
102
+
103
+ const ZOOM_BTN_STYLE: CSSProperties = {
104
+ width: 20,
105
+ height: 18,
106
+ border: 'none',
107
+ borderRadius: 4,
108
+ background: 'transparent',
109
+ color: 'var(--cs-chrome-fg, #201f1e)',
110
+ cursor: 'pointer',
111
+ fontSize: 14,
112
+ lineHeight: 1,
113
+ padding: 0,
114
+ };
115
+
116
+ const ZOOM_LEVEL_STYLE: CSSProperties = {
117
+ minWidth: 40,
118
+ height: 18,
119
+ border: 'none',
120
+ borderRadius: 4,
121
+ background: 'transparent',
122
+ color: 'var(--cs-chrome-muted, #4b5563)',
123
+ cursor: 'pointer',
124
+ font: 'inherit',
125
+ fontSize: 12,
126
+ textAlign: 'center',
127
+ };
128
+
129
+ export interface StatusBarProps {
130
+ api: CasualSheetsAPI | null;
131
+ }
132
+
133
+ export function StatusBar({ api }: StatusBarProps) {
134
+ const [stats, setStats] = useState<Stats | null>(null);
135
+ const [zoom, setZoom] = useState(1);
136
+
137
+ useEffect(() => {
138
+ if (!api) return;
139
+ const refresh = () => {
140
+ setStats(readStats(api));
141
+ setZoom(readZoom(api));
142
+ };
143
+ refresh();
144
+ const injector = (api.univer as unknown as { _injector?: { get(t: unknown): unknown } })
145
+ ._injector;
146
+ const cmd = injector?.get(ICommandService) as
147
+ | { onCommandExecuted: (cb: () => void) => { dispose: () => void } }
148
+ | undefined;
149
+ const sub = cmd?.onCommandExecuted(() => refresh());
150
+ return () => sub?.dispose();
151
+ }, [api]);
152
+
153
+ // Drive the zoom OPERATION directly — see file header for why not the commands.
154
+ const setZoomRatio = (ratio: number) => {
155
+ const wb = api?.univer.getActiveWorkbook();
156
+ const sheet = wb?.getActiveSheet();
157
+ if (!wb || !sheet) return;
158
+ const clamped = Math.min(4, Math.max(0.1, ratio));
159
+ void api?.executeCommand('sheet.operation.set-zoom-ratio', {
160
+ unitId: wb.getId(),
161
+ subUnitId: sheet.getSheetId(),
162
+ zoomRatio: clamped,
163
+ });
164
+ // Optimistic local update — the onCommandExecuted refresh will reconcile.
165
+ setZoom(clamped);
166
+ };
167
+ const zoomBy = (delta: number) => setZoomRatio(Math.round((zoom + delta) * 100) / 100);
168
+ const resetZoom = () => setZoomRatio(1);
169
+
170
+ return (
171
+ <div style={BAR_STYLE} data-testid="casual-sheets-status-bar">
172
+ {stats && (
173
+ <>
174
+ {stats.numCount > 0 && <span data-stat="average">Average: {fmt(stats.avg)}</span>}
175
+ <span data-stat="count">Count: {stats.count}</span>
176
+ {stats.numCount > 0 && (
177
+ <>
178
+ <span data-stat="num-count">Numerical Count: {stats.numCount}</span>
179
+ <span data-stat="min">Min: {fmt(stats.min)}</span>
180
+ <span data-stat="max">Max: {fmt(stats.max)}</span>
181
+ <span data-stat="sum">Sum: {fmt(stats.sum)}</span>
182
+ </>
183
+ )}
184
+ </>
185
+ )}
186
+ <span style={ZOOM_GROUP_STYLE} data-testid="cs-zoom">
187
+ <button
188
+ type="button"
189
+ style={ZOOM_BTN_STYLE}
190
+ aria-label="Zoom out"
191
+ title="Zoom out"
192
+ data-testid="cs-zoom-out"
193
+ disabled={!api}
194
+ onMouseDown={(e) => {
195
+ e.preventDefault();
196
+ zoomBy(-0.1);
197
+ }}
198
+ >
199
+
200
+ </button>
201
+ <button
202
+ type="button"
203
+ style={ZOOM_LEVEL_STYLE}
204
+ aria-label="Reset zoom to 100%"
205
+ title="Reset zoom to 100%"
206
+ data-testid="cs-zoom-level"
207
+ disabled={!api}
208
+ onMouseDown={(e) => {
209
+ e.preventDefault();
210
+ resetZoom();
211
+ }}
212
+ >
213
+ {Math.round(zoom * 100)}%
214
+ </button>
215
+ <button
216
+ type="button"
217
+ style={ZOOM_BTN_STYLE}
218
+ aria-label="Zoom in"
219
+ title="Zoom in"
220
+ data-testid="cs-zoom-in"
221
+ disabled={!api}
222
+ onMouseDown={(e) => {
223
+ e.preventDefault();
224
+ zoomBy(0.1);
225
+ }}
226
+ >
227
+ +
228
+ </button>
229
+ </span>
230
+ </div>
231
+ );
232
+ }