@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,336 @@
1
+ /**
2
+ * MenuBar — the built-in dropdown menu row for `<CasualSheets chrome>`.
3
+ *
4
+ * Office-style horizontal menu strip (no logo, no title — the host frames the
5
+ * editor with its own bar): Edit · Insert · Format · Data. Each button opens a
6
+ * dropdown of items that dispatch a single verified Univer command through
7
+ * `CasualSheetsAPI.executeCommand`, then close the menu.
8
+ *
9
+ * Self-contained: only one menu is open at a time; Escape and an outside
10
+ * pointerdown close it. Every command id below was verified against the fork
11
+ * (`vendor/univer-revamp/packages`); items whose command can't be verified are
12
+ * omitted rather than dispatched blindly.
13
+ */
14
+
15
+ import { useEffect, useRef, useState, type CSSProperties } from 'react';
16
+ import type { CasualSheetsAPI } from '../sheets/api';
17
+ import { Icon } from './Icon';
18
+ import { ensureChromeFonts } from './fonts';
19
+
20
+ type MenuId = 'edit' | 'insert' | 'format' | 'data' | 'view';
21
+
22
+ interface MenuItem {
23
+ /** Stable id — drives the per-item testid `cs-menuitem-<id>`. */
24
+ id: string;
25
+ label: string;
26
+ command: string;
27
+ icon?: string;
28
+ params?: object;
29
+ }
30
+
31
+ interface Menu {
32
+ id: MenuId;
33
+ label: string;
34
+ items: MenuItem[];
35
+ }
36
+
37
+ // All command ids below verified via:
38
+ // grep -rhoE "id: '(sheet\.command\.[a-z.-]+)'" vendor/univer-revamp/packages | sort -u
39
+ // (undo/redo: univer.command.undo / .redo in packages/core undoredo)
40
+ const MENUS: Menu[] = [
41
+ {
42
+ id: 'edit',
43
+ label: 'Edit',
44
+ items: [
45
+ { id: 'undo', label: 'Undo', command: 'univer.command.undo', icon: 'undo' },
46
+ { id: 'redo', label: 'Redo', command: 'univer.command.redo', icon: 'redo' },
47
+ { id: 'cut', label: 'Cut', command: 'univer.command.cut', icon: 'content_cut' },
48
+ { id: 'copy', label: 'Copy', command: 'univer.command.copy', icon: 'content_copy' },
49
+ { id: 'paste', label: 'Paste', command: 'univer.command.paste', icon: 'content_paste' },
50
+ ],
51
+ },
52
+ {
53
+ id: 'insert',
54
+ label: 'Insert',
55
+ items: [
56
+ {
57
+ id: 'insert-row',
58
+ label: 'Insert row above',
59
+ command: 'sheet.command.insert-row-before',
60
+ icon: 'add_row_above',
61
+ },
62
+ {
63
+ id: 'insert-col',
64
+ label: 'Insert column left',
65
+ command: 'sheet.command.insert-col-before',
66
+ icon: 'add_column_left',
67
+ },
68
+ {
69
+ id: 'delete-row',
70
+ label: 'Delete row',
71
+ command: 'sheet.command.remove-row',
72
+ icon: 'delete',
73
+ },
74
+ {
75
+ id: 'delete-col',
76
+ label: 'Delete column',
77
+ command: 'sheet.command.remove-col',
78
+ icon: 'delete',
79
+ },
80
+ ],
81
+ },
82
+ {
83
+ id: 'format',
84
+ label: 'Format',
85
+ items: [
86
+ { id: 'bold', label: 'Bold', command: 'sheet.command.set-range-bold', icon: 'format_bold' },
87
+ {
88
+ id: 'italic',
89
+ label: 'Italic',
90
+ command: 'sheet.command.set-range-italic',
91
+ icon: 'format_italic',
92
+ },
93
+ {
94
+ id: 'underline',
95
+ label: 'Underline',
96
+ command: 'sheet.command.set-range-underline',
97
+ icon: 'format_underlined',
98
+ },
99
+ {
100
+ id: 'wrap-text',
101
+ label: 'Wrap text',
102
+ command: 'sheet.command.set-text-wrap',
103
+ icon: 'wrap_text',
104
+ },
105
+ {
106
+ id: 'clear-format',
107
+ label: 'Clear formatting',
108
+ command: 'sheet.command.clear-selection-format',
109
+ icon: 'format_clear',
110
+ },
111
+ ],
112
+ },
113
+ {
114
+ id: 'data',
115
+ label: 'Data',
116
+ items: [
117
+ {
118
+ id: 'sort-asc',
119
+ label: 'Sort ascending',
120
+ command: 'sheet.command.sort-range-asc',
121
+ icon: 'arrow_upward',
122
+ },
123
+ {
124
+ id: 'sort-desc',
125
+ label: 'Sort descending',
126
+ command: 'sheet.command.sort-range-desc',
127
+ icon: 'arrow_downward',
128
+ },
129
+ {
130
+ id: 'toggle-filter',
131
+ label: 'Toggle filter',
132
+ command: 'sheet.command.smart-toggle-filter',
133
+ icon: 'filter_alt',
134
+ },
135
+ ],
136
+ },
137
+ {
138
+ id: 'view',
139
+ label: 'View',
140
+ items: [
141
+ {
142
+ id: 'freeze',
143
+ label: 'Freeze panes',
144
+ command: 'sheet.command.set-selection-frozen',
145
+ icon: 'table_view',
146
+ },
147
+ {
148
+ id: 'unfreeze',
149
+ label: 'Unfreeze panes',
150
+ command: 'sheet.command.cancel-frozen',
151
+ icon: 'grid_off',
152
+ },
153
+ {
154
+ id: 'toggle-gridlines',
155
+ label: 'Toggle gridlines',
156
+ command: 'sheet.command.toggle-gridlines',
157
+ icon: 'grid_on',
158
+ },
159
+ ],
160
+ },
161
+ ];
162
+
163
+ const BAR_STYLE: CSSProperties = {
164
+ position: 'relative',
165
+ display: 'flex',
166
+ alignItems: 'center',
167
+ gap: 2,
168
+ padding: '2px 6px',
169
+ borderBottom: '1px solid var(--cs-chrome-border, #e6e9ee)',
170
+ background: 'var(--cs-chrome-bg, #eef1f5)',
171
+ flex: '0 0 auto',
172
+ userSelect: 'none',
173
+ font: 'inherit',
174
+ fontSize: 13,
175
+ };
176
+
177
+ const MENU_BTN_STYLE: CSSProperties = {
178
+ height: 26,
179
+ display: 'inline-flex',
180
+ alignItems: 'center',
181
+ padding: '0 10px',
182
+ border: 'none',
183
+ borderRadius: 6,
184
+ background: 'transparent',
185
+ cursor: 'pointer',
186
+ color: 'var(--cs-chrome-fg, #201f1e)',
187
+ font: 'inherit',
188
+ fontSize: 13,
189
+ };
190
+
191
+ const DROPDOWN_STYLE: CSSProperties = {
192
+ position: 'absolute',
193
+ top: '100%',
194
+ minWidth: 200,
195
+ marginTop: 2,
196
+ padding: 4,
197
+ display: 'flex',
198
+ flexDirection: 'column',
199
+ border: '1px solid var(--cs-chrome-border, #e6e9ee)',
200
+ borderRadius: 8,
201
+ background: 'var(--cs-chrome-input-bg, #fff)',
202
+ boxShadow: '0 6px 20px rgba(0,0,0,0.16)',
203
+ zIndex: 1000,
204
+ };
205
+
206
+ const ITEM_STYLE: CSSProperties = {
207
+ display: 'flex',
208
+ alignItems: 'center',
209
+ gap: 10,
210
+ width: '100%',
211
+ height: 30,
212
+ padding: '0 10px',
213
+ border: 'none',
214
+ borderRadius: 6,
215
+ background: 'transparent',
216
+ cursor: 'pointer',
217
+ color: 'var(--cs-chrome-fg, #201f1e)',
218
+ font: 'inherit',
219
+ fontSize: 13,
220
+ textAlign: 'left',
221
+ };
222
+
223
+ export interface MenuBarProps {
224
+ /** Live API, or `null` until the editor is ready. */
225
+ api: CasualSheetsAPI | null;
226
+ }
227
+
228
+ export function MenuBar({ api }: MenuBarProps) {
229
+ const [open, setOpen] = useState<MenuId | null>(null);
230
+ const rootRef = useRef<HTMLDivElement>(null);
231
+
232
+ useEffect(() => {
233
+ ensureChromeFonts();
234
+ }, []);
235
+
236
+ // Close on Escape + on a pointerdown outside the menu bar.
237
+ useEffect(() => {
238
+ if (open === null) return;
239
+ const onKey = (e: KeyboardEvent) => {
240
+ if (e.key === 'Escape') setOpen(null);
241
+ };
242
+ const onDown = (e: PointerEvent) => {
243
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(null);
244
+ };
245
+ document.addEventListener('keydown', onKey);
246
+ document.addEventListener('pointerdown', onDown, true);
247
+ return () => {
248
+ document.removeEventListener('keydown', onKey);
249
+ document.removeEventListener('pointerdown', onDown, true);
250
+ };
251
+ }, [open]);
252
+
253
+ const run = (item: MenuItem) => {
254
+ setOpen(null);
255
+ void api?.executeCommand(item.command, item.params);
256
+ };
257
+
258
+ return (
259
+ <div
260
+ ref={rootRef}
261
+ style={BAR_STYLE}
262
+ data-testid="cs-menubar"
263
+ role="menubar"
264
+ aria-label="Menu bar"
265
+ >
266
+ {MENUS.map((menu) => {
267
+ const isOpen = open === menu.id;
268
+ return (
269
+ <div key={menu.id} style={{ position: 'relative' }}>
270
+ <button
271
+ type="button"
272
+ data-menu={menu.id}
273
+ aria-haspopup="menu"
274
+ aria-expanded={isOpen}
275
+ style={{
276
+ ...MENU_BTN_STYLE,
277
+ background: isOpen ? 'var(--cs-chrome-active, #e6f3f7)' : 'transparent',
278
+ color: isOpen ? 'var(--cs-chrome-active-fg, #0e7490)' : MENU_BTN_STYLE.color,
279
+ }}
280
+ // mousedown (not click) so the grid selection isn't lost first;
281
+ // toggle the menu open/closed.
282
+ onMouseDown={(e) => {
283
+ e.preventDefault();
284
+ setOpen((cur) => (cur === menu.id ? null : menu.id));
285
+ }}
286
+ onMouseEnter={(e) => {
287
+ // Hover-to-switch once a menu is already open (Office behaviour).
288
+ if (open !== null && open !== menu.id) setOpen(menu.id);
289
+ else if (!isOpen)
290
+ e.currentTarget.style.background = 'var(--cs-chrome-hover, rgba(0,0,0,0.06))';
291
+ }}
292
+ onMouseLeave={(e) => {
293
+ e.currentTarget.style.background = isOpen
294
+ ? 'var(--cs-chrome-active, #e6f3f7)'
295
+ : 'transparent';
296
+ }}
297
+ >
298
+ {menu.label}
299
+ </button>
300
+ {isOpen && (
301
+ <div style={DROPDOWN_STYLE} role="menu" aria-label={menu.label}>
302
+ {menu.items.map((item) => (
303
+ <button
304
+ key={item.id}
305
+ type="button"
306
+ role="menuitem"
307
+ data-testid={`cs-menuitem-${item.id}`}
308
+ disabled={!api}
309
+ style={{ ...ITEM_STYLE, opacity: api ? 1 : 0.5 }}
310
+ onMouseDown={(e) => {
311
+ e.preventDefault();
312
+ run(item);
313
+ }}
314
+ onMouseEnter={(e) => {
315
+ e.currentTarget.style.background = 'var(--cs-chrome-hover, rgba(0,0,0,0.06))';
316
+ }}
317
+ onMouseLeave={(e) => {
318
+ e.currentTarget.style.background = 'transparent';
319
+ }}
320
+ >
321
+ {item.icon ? (
322
+ <Icon name={item.icon} size={18} />
323
+ ) : (
324
+ <span style={{ width: 18 }} aria-hidden />
325
+ )}
326
+ <span>{item.label}</span>
327
+ </button>
328
+ ))}
329
+ </div>
330
+ )}
331
+ </div>
332
+ );
333
+ })}
334
+ </div>
335
+ );
336
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * NameBox — the Excel "Name Box" for `<CasualSheets chrome>`.
3
+ *
4
+ * A small editable box that mirrors the active cell's A1 reference live, and
5
+ * lets you navigate by typing a reference (e.g. `B5` or `A1:C3`) + Enter. Self-
6
+ * contained: reads the active selection through `CasualSheetsAPI` and navigates
7
+ * through the FUniver facade (`getRange(a1).activate()`) — no app context, no
8
+ * named-range support (defined names land with the rich name-box later).
9
+ *
10
+ * The orchestrator uses this to replace the static name-box span currently
11
+ * inside FormulaBar.tsx; this component does NOT edit FormulaBar.
12
+ */
13
+
14
+ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
15
+ import { ICommandService } from '@univerjs/core';
16
+ import type { CasualSheetsAPI } from '../sheets/api';
17
+ import { Icon } from './Icon';
18
+
19
+ /** A defined name as surfaced by the FWorkbook facade. */
20
+ interface DefinedNameEntry {
21
+ name: string;
22
+ ref: string;
23
+ }
24
+
25
+ /**
26
+ * Read the active workbook's defined names through the FWorkbook facade
27
+ * (`getDefinedNames()` → `FDefinedName[]`, each with `getName()` +
28
+ * `getFormulaOrRefString()`). Returns [] when the facade/API is unavailable —
29
+ * never invents an API.
30
+ */
31
+ function readDefinedNames(api: CasualSheetsAPI | null): DefinedNameEntry[] {
32
+ if (!api) return [];
33
+ try {
34
+ const wb = api.univer.getActiveWorkbook() as unknown as {
35
+ getDefinedNames?: () => Array<{
36
+ getName?: () => string;
37
+ getFormulaOrRefString?: () => string;
38
+ }>;
39
+ } | null;
40
+ const names = wb?.getDefinedNames?.();
41
+ if (!names) return [];
42
+ return names
43
+ .map((dn) => ({
44
+ name: dn.getName?.() ?? '',
45
+ ref: dn.getFormulaOrRefString?.() ?? '',
46
+ }))
47
+ .filter((e) => e.name !== '');
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ /** A1 column letters from a 0-based column index (0→A, 26→AA). */
54
+ function colToLetters(col: number): string {
55
+ let s = '';
56
+ let n = col;
57
+ do {
58
+ s = String.fromCharCode(65 + (n % 26)) + s;
59
+ n = Math.floor(n / 26) - 1;
60
+ } while (n >= 0);
61
+ return s;
62
+ }
63
+
64
+ /** The active selection's top-left A1 reference, or '' when unavailable. */
65
+ function readActiveRef(api: CasualSheetsAPI): string {
66
+ const sel = api.getSelection();
67
+ if (!sel) return '';
68
+ const { startRow, startColumn } = sel.range;
69
+ return colToLetters(startColumn) + (startRow + 1);
70
+ }
71
+
72
+ const BOX_STYLE: CSSProperties = {
73
+ flex: '0 0 auto',
74
+ width: 80,
75
+ height: 24,
76
+ padding: '0 8px',
77
+ border: '1px solid var(--cs-chrome-border, #e6e9ee)',
78
+ borderRadius: 4,
79
+ background: 'var(--cs-chrome-input-bg, #fff)',
80
+ color: 'var(--cs-chrome-fg, #201f1e)',
81
+ font: 'inherit',
82
+ fontSize: 13,
83
+ textAlign: 'center',
84
+ boxSizing: 'border-box',
85
+ };
86
+
87
+ const ROOT_STYLE: CSSProperties = {
88
+ flex: '0 0 auto',
89
+ display: 'inline-flex',
90
+ alignItems: 'center',
91
+ position: 'relative',
92
+ };
93
+
94
+ const DROPDOWN_BTN_STYLE: CSSProperties = {
95
+ flex: '0 0 auto',
96
+ width: 20,
97
+ height: 24,
98
+ marginLeft: 2,
99
+ display: 'inline-flex',
100
+ alignItems: 'center',
101
+ justifyContent: 'center',
102
+ border: 'none',
103
+ borderRadius: 4,
104
+ background: 'transparent',
105
+ color: 'var(--cs-chrome-fg, #201f1e)',
106
+ cursor: 'pointer',
107
+ padding: 0,
108
+ };
109
+
110
+ const MENU_STYLE: CSSProperties = {
111
+ position: 'absolute',
112
+ top: '100%',
113
+ left: 0,
114
+ marginTop: 4,
115
+ zIndex: 1000,
116
+ minWidth: 160,
117
+ maxHeight: 280,
118
+ overflowY: 'auto',
119
+ padding: 4,
120
+ borderRadius: 8,
121
+ border: '1px solid var(--cs-chrome-border, #e6e9ee)',
122
+ background: 'var(--cs-chrome-input-bg, #fff)',
123
+ boxShadow: '0 6px 20px rgba(0,0,0,0.18)',
124
+ };
125
+
126
+ const MENU_ITEM_STYLE: CSSProperties = {
127
+ display: 'flex',
128
+ alignItems: 'baseline',
129
+ justifyContent: 'space-between',
130
+ gap: 12,
131
+ width: '100%',
132
+ height: 28,
133
+ padding: '0 8px',
134
+ border: 'none',
135
+ borderRadius: 6,
136
+ background: 'transparent',
137
+ color: 'var(--cs-chrome-fg, #201f1e)',
138
+ font: 'inherit',
139
+ fontSize: 13,
140
+ textAlign: 'left',
141
+ cursor: 'pointer',
142
+ boxSizing: 'border-box',
143
+ };
144
+
145
+ const MENU_EMPTY_STYLE: CSSProperties = {
146
+ padding: '6px 8px',
147
+ color: 'var(--cs-chrome-muted, #8a8886)',
148
+ fontSize: 12,
149
+ whiteSpace: 'nowrap',
150
+ };
151
+
152
+ const ITEM_REF_STYLE: CSSProperties = {
153
+ color: 'var(--cs-chrome-muted, #8a8886)',
154
+ fontSize: 11,
155
+ flex: '0 0 auto',
156
+ };
157
+
158
+ export interface NameBoxProps {
159
+ /** Live API, or `null` until the editor is ready. */
160
+ api: CasualSheetsAPI | null;
161
+ }
162
+
163
+ export function NameBox({ api }: NameBoxProps) {
164
+ // The A1 reference of the active cell.
165
+ const [ref, setRef] = useState('');
166
+ // null = mirror the active cell; a string = the user is typing.
167
+ const [draft, setDraft] = useState<string | null>(null);
168
+ const draftRef = useRef<string | null>(null);
169
+ draftRef.current = draft;
170
+ const inputRef = useRef<HTMLInputElement>(null);
171
+ // The defined-names dropdown: open state, current entries, and a root ref for
172
+ // outside-pointerdown detection (mirrors ColorPicker's popover pattern).
173
+ const [open, setOpen] = useState(false);
174
+ const [names, setNames] = useState<DefinedNameEntry[]>([]);
175
+ const rootRef = useRef<HTMLDivElement>(null);
176
+
177
+ // Reflect the active cell: subscribe to command activity (covers selection
178
+ // moves) and re-read the reference — unless the user is mid-edit.
179
+ useEffect(() => {
180
+ if (!api) return;
181
+ const refresh = () => {
182
+ if (draftRef.current !== null) return;
183
+ setRef(readActiveRef(api));
184
+ };
185
+ refresh();
186
+ const injector = (api.univer as unknown as { _injector?: { get(t: unknown): unknown } })
187
+ ._injector;
188
+ const cmd = injector?.get(ICommandService) as
189
+ | { onCommandExecuted: (cb: () => void) => { dispose: () => void } }
190
+ | undefined;
191
+ const sub = cmd?.onCommandExecuted(() => refresh());
192
+ return () => sub?.dispose();
193
+ }, [api]);
194
+
195
+ // While the dropdown is open, close it on Escape or any outside pointerdown.
196
+ useEffect(() => {
197
+ if (!open) return;
198
+ const onPointerDown = (e: PointerEvent) => {
199
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
200
+ };
201
+ const onKeyDown = (e: globalThis.KeyboardEvent) => {
202
+ if (e.key === 'Escape') setOpen(false);
203
+ };
204
+ document.addEventListener('pointerdown', onPointerDown, true);
205
+ document.addEventListener('keydown', onKeyDown);
206
+ return () => {
207
+ document.removeEventListener('pointerdown', onPointerDown, true);
208
+ document.removeEventListener('keydown', onKeyDown);
209
+ };
210
+ }, [open]);
211
+
212
+ /** Navigate to the typed A1 reference / range. Invalid input is ignored. */
213
+ const navigate = (text: string) => {
214
+ const t = text.trim();
215
+ setDraft(null);
216
+ if (!api || t === '') {
217
+ setRef(readActiveRef(api ?? ({} as CasualSheetsAPI)));
218
+ return;
219
+ }
220
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
221
+ if (!sheet) return;
222
+ try {
223
+ // FRange.getRange parses an A1 string ("B5") or range ("A1:C3").
224
+ const range = (sheet as unknown as { getRange(a1: string): { activate(): void } }).getRange(
225
+ t,
226
+ );
227
+ range.activate();
228
+ } catch {
229
+ // Invalid reference — fall back to the current selection.
230
+ setRef(readActiveRef(api));
231
+ }
232
+ };
233
+
234
+ // Open the dropdown: refresh the defined-name list from the live workbook,
235
+ // then toggle. mousedown (not click) so the grid selection isn't lost first.
236
+ const toggleDropdown = () => {
237
+ setOpen((cur) => {
238
+ const next = !cur;
239
+ if (next) setNames(readDefinedNames(api));
240
+ return next;
241
+ });
242
+ };
243
+
244
+ // Navigate to a defined name's range. The ref may carry a sheet prefix
245
+ // ("Sheet1!$A$1") and absolute `$` markers — strip both, then reuse the same
246
+ // getRange(...).activate() path the typed-reference navigation uses.
247
+ const navigateName = (entry: DefinedNameEntry) => {
248
+ setOpen(false);
249
+ const raw = entry.ref.trim();
250
+ if (raw === '') return;
251
+ const afterSheet = raw.includes('!') ? raw.slice(raw.lastIndexOf('!') + 1) : raw;
252
+ const a1 = afterSheet.replace(/\$/g, '');
253
+ navigate(a1);
254
+ };
255
+
256
+ const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
257
+ if (e.key === 'Enter') {
258
+ e.preventDefault();
259
+ navigate((e.target as HTMLInputElement).value);
260
+ (e.target as HTMLInputElement).blur();
261
+ } else if (e.key === 'Escape') {
262
+ e.preventDefault();
263
+ setDraft(null);
264
+ setRef(api ? readActiveRef(api) : '');
265
+ (e.target as HTMLInputElement).blur();
266
+ }
267
+ };
268
+
269
+ const shown = draft ?? ref;
270
+
271
+ return (
272
+ <div ref={rootRef} style={ROOT_STYLE}>
273
+ <input
274
+ ref={inputRef}
275
+ type="text"
276
+ aria-label="Name box"
277
+ title="Name box"
278
+ data-testid="cs-namebox-input"
279
+ style={BOX_STYLE}
280
+ value={shown}
281
+ disabled={!api}
282
+ onChange={(e) => setDraft(e.target.value)}
283
+ onKeyDown={onKeyDown}
284
+ onFocus={(e) => e.currentTarget.select()}
285
+ onBlur={() => {
286
+ // Abandon any uncommitted edit without navigating.
287
+ if (draftRef.current !== null) {
288
+ setDraft(null);
289
+ setRef(api ? readActiveRef(api) : '');
290
+ }
291
+ }}
292
+ />
293
+ <button
294
+ type="button"
295
+ title="Defined names"
296
+ aria-label="Defined names"
297
+ aria-haspopup="true"
298
+ aria-expanded={open}
299
+ data-testid="cs-namebox-dropdown"
300
+ style={{
301
+ ...DROPDOWN_BTN_STYLE,
302
+ background: open ? 'var(--cs-chrome-active, #e6f3f7)' : 'transparent',
303
+ color: open ? 'var(--cs-chrome-active-fg, #0e7490)' : DROPDOWN_BTN_STYLE.color,
304
+ }}
305
+ disabled={!api}
306
+ // mousedown (not click) so the grid's selection isn't lost first.
307
+ onMouseDown={(e) => {
308
+ e.preventDefault();
309
+ toggleDropdown();
310
+ }}
311
+ >
312
+ <Icon name="expand_more" size={18} />
313
+ </button>
314
+ {open && (
315
+ <div style={MENU_STYLE} role="menu" data-testid="cs-namebox-menu">
316
+ {names.length === 0 ? (
317
+ <div style={MENU_EMPTY_STYLE}>No names</div>
318
+ ) : (
319
+ names.map((entry, i) => (
320
+ <button
321
+ key={entry.name}
322
+ type="button"
323
+ role="menuitem"
324
+ title={entry.ref || entry.name}
325
+ data-testid={`cs-namebox-name-${i}`}
326
+ style={MENU_ITEM_STYLE}
327
+ onMouseDown={(e) => {
328
+ e.preventDefault();
329
+ navigateName(entry);
330
+ }}
331
+ onMouseEnter={(e) => {
332
+ e.currentTarget.style.background = 'var(--cs-chrome-hover, rgba(0,0,0,0.06))';
333
+ }}
334
+ onMouseLeave={(e) => {
335
+ e.currentTarget.style.background = 'transparent';
336
+ }}
337
+ >
338
+ <span>{entry.name}</span>
339
+ {entry.ref && <span style={ITEM_REF_STYLE}>{entry.ref}</span>}
340
+ </button>
341
+ ))
342
+ )}
343
+ </div>
344
+ )}
345
+ </div>
346
+ );
347
+ }