@veltra/sheet 2.0.1 → 2.1.1

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 (211) hide show
  1. package/dist/index.d.ts +4 -26
  2. package/dist/index.js +3 -23
  3. package/dist/tools/builtin.js +279 -8
  4. package/dist/tools/context.d.ts +54 -11
  5. package/dist/tools/context.js +28 -5
  6. package/dist/tools/download.js +68 -0
  7. package/dist/tools/export.worker.d.ts +16 -0
  8. package/dist/tools/export.worker.js +29 -0
  9. package/dist/tools/registry.d.ts +7 -4
  10. package/dist/tools/registry.js +1 -3
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/types/sheet.d.ts +6 -5
  13. package/dist/vue/formula-bar.js +393 -0
  14. package/dist/vue/formula-suggest-list.js +66 -0
  15. package/dist/vue/import-file.js +140 -0
  16. package/dist/vue/insert-count-menu-item.js +68 -0
  17. package/dist/vue/insert-image.js +79 -0
  18. package/dist/vue/popup-helpers.js +73 -0
  19. package/dist/vue/popups/border-popup.js +75 -0
  20. package/dist/vue/popups/border-preset-glyph.js +106 -0
  21. package/dist/vue/popups/export-popup.js +51 -0
  22. package/dist/vue/popups/fill-color-popup.js +37 -0
  23. package/dist/vue/popups/find-popup.js +113 -0
  24. package/dist/vue/popups/font-color-popup.js +37 -0
  25. package/dist/vue/popups/font-size-popup.js +63 -0
  26. package/dist/vue/popups/import.worker.d.ts +21 -0
  27. package/dist/vue/popups/import.worker.js +41 -0
  28. package/dist/vue/popups/insert-image-popup.js +38 -0
  29. package/dist/vue/sheet-context-menu.js +200 -0
  30. package/dist/vue/sheet-tabs.js +162 -0
  31. package/dist/vue/sheet-toolbar.js +106 -0
  32. package/dist/vue/sheet.js +144 -164
  33. package/dist/vue/style.d.ts +11 -1
  34. package/dist/vue/style.js +10 -0
  35. package/dist/vue/style2.css +564 -3
  36. package/dist/vue/use-find-replace.js +161 -0
  37. package/dist/vue/use-formula-ref-select.js +37 -0
  38. package/dist/vue/use-formula-suggest.js +126 -0
  39. package/dist/vue/use-sheet-grid.js +180 -0
  40. package/dist/vue/use-sheet-state.js +122 -0
  41. package/dist/vue/use-sheet-tabs-bar.js +90 -0
  42. package/dist/vue/use-tool-groups.js +42 -0
  43. package/dist/vue/use-tool-popup.js +123 -0
  44. package/dist/vue/use-toolbar-scroll.js +72 -0
  45. package/package.json +12 -7
  46. package/src/env.d.ts +11 -0
  47. package/src/index.ts +203 -14
  48. package/src/tools/__test__/builtin.test.ts +208 -66
  49. package/src/tools/__test__/context.test.ts +62 -2
  50. package/src/tools/builtin.ts +350 -6
  51. package/src/tools/context.ts +89 -11
  52. package/src/tools/download.ts +82 -0
  53. package/src/tools/export.worker.ts +61 -0
  54. package/src/tools/registry.ts +16 -1
  55. package/src/types/index.ts +6 -0
  56. package/src/types/sheet.ts +5 -3
  57. package/src/vue/__test__/formula-bar.test.ts +455 -0
  58. package/src/vue/__test__/import-file.test.ts +183 -0
  59. package/src/vue/__test__/insert-count-menu-item.test.ts +123 -0
  60. package/src/vue/__test__/insert-image-popup.test.ts +89 -0
  61. package/src/vue/__test__/insert-image.test.ts +100 -0
  62. package/src/vue/__test__/sheet-component.test.ts +716 -27
  63. package/src/vue/__test__/sheet-context-menu.test.ts +206 -0
  64. package/src/vue/__test__/use-formula-ref-select.test.ts +35 -0
  65. package/src/vue/__test__/use-formula-suggest.test.ts +83 -0
  66. package/src/vue/formula-bar.vue +473 -0
  67. package/src/vue/formula-suggest-list.vue +64 -0
  68. package/src/vue/import-file.ts +173 -0
  69. package/src/vue/insert-count-menu-item.vue +69 -0
  70. package/src/vue/insert-image.ts +90 -0
  71. package/src/vue/parsing.ts +11 -0
  72. package/src/vue/popup-helpers.ts +50 -0
  73. package/src/vue/popups/border-popup.vue +93 -0
  74. package/src/vue/popups/border-preset-glyph.vue +111 -0
  75. package/src/vue/popups/export-popup.vue +52 -0
  76. package/src/vue/popups/fill-color-popup.vue +39 -0
  77. package/src/vue/popups/find-popup.vue +111 -0
  78. package/src/vue/popups/font-color-popup.vue +39 -0
  79. package/src/vue/popups/font-size-popup.vue +52 -0
  80. package/src/vue/popups/import.worker.ts +66 -0
  81. package/src/vue/popups/insert-image-popup.vue +39 -0
  82. package/src/vue/sheet-context-menu.ts +229 -0
  83. package/src/vue/sheet-tabs.vue +162 -0
  84. package/src/vue/sheet-toolbar.vue +91 -0
  85. package/src/vue/sheet.vue +161 -186
  86. package/src/vue/style.scss +646 -3
  87. package/src/vue/style.ts +11 -0
  88. package/src/vue/use-find-replace.ts +200 -0
  89. package/src/vue/use-formula-ref-select.ts +34 -0
  90. package/src/vue/use-formula-suggest.ts +150 -0
  91. package/src/vue/use-sheet-grid.ts +243 -0
  92. package/src/vue/use-sheet-state.ts +161 -0
  93. package/src/vue/use-sheet-tabs-bar.ts +115 -0
  94. package/src/vue/use-tool-groups.ts +61 -0
  95. package/src/vue/use-tool-popup.ts +150 -0
  96. package/src/vue/use-toolbar-scroll.ts +95 -0
  97. package/dist/core/address.d.ts +0 -60
  98. package/dist/core/address.js +0 -142
  99. package/dist/core/address.js.map +0 -1
  100. package/dist/core/cell-store.d.ts +0 -61
  101. package/dist/core/cell-store.js +0 -135
  102. package/dist/core/cell-store.js.map +0 -1
  103. package/dist/core/command/default-registry.d.ts +0 -10
  104. package/dist/core/command/default-registry.js +0 -18
  105. package/dist/core/command/default-registry.js.map +0 -1
  106. package/dist/core/command/history.d.ts +0 -64
  107. package/dist/core/command/history.js +0 -127
  108. package/dist/core/command/history.js.map +0 -1
  109. package/dist/core/command/merge-cells.d.ts +0 -21
  110. package/dist/core/command/merge-cells.js +0 -74
  111. package/dist/core/command/merge-cells.js.map +0 -1
  112. package/dist/core/command/registry.d.ts +0 -17
  113. package/dist/core/command/registry.js +0 -26
  114. package/dist/core/command/registry.js.map +0 -1
  115. package/dist/core/command/set-cell-formula.d.ts +0 -17
  116. package/dist/core/command/set-cell-formula.js +0 -29
  117. package/dist/core/command/set-cell-formula.js.map +0 -1
  118. package/dist/core/command/set-cell-value.d.ts +0 -22
  119. package/dist/core/command/set-cell-value.js +0 -35
  120. package/dist/core/command/set-cell-value.js.map +0 -1
  121. package/dist/core/command/types.d.ts +0 -68
  122. package/dist/core/events.js +0 -29
  123. package/dist/core/events.js.map +0 -1
  124. package/dist/core/fill.d.ts +0 -34
  125. package/dist/core/fill.js +0 -320
  126. package/dist/core/fill.js.map +0 -1
  127. package/dist/core/formula/ast.d.ts +0 -58
  128. package/dist/core/formula/ast.js +0 -38
  129. package/dist/core/formula/ast.js.map +0 -1
  130. package/dist/core/formula/dependency-graph.d.ts +0 -70
  131. package/dist/core/formula/dependency-graph.js +0 -269
  132. package/dist/core/formula/dependency-graph.js.map +0 -1
  133. package/dist/core/formula/errors.d.ts +0 -25
  134. package/dist/core/formula/errors.js +0 -36
  135. package/dist/core/formula/errors.js.map +0 -1
  136. package/dist/core/formula/evaluator.d.ts +0 -41
  137. package/dist/core/formula/evaluator.js +0 -154
  138. package/dist/core/formula/evaluator.js.map +0 -1
  139. package/dist/core/formula/functions.d.ts +0 -30
  140. package/dist/core/formula/functions.js +0 -209
  141. package/dist/core/formula/functions.js.map +0 -1
  142. package/dist/core/formula/parser.d.ts +0 -7
  143. package/dist/core/formula/parser.js +0 -208
  144. package/dist/core/formula/parser.js.map +0 -1
  145. package/dist/core/formula/tokenizer.d.ts +0 -37
  146. package/dist/core/formula/tokenizer.js +0 -146
  147. package/dist/core/formula/tokenizer.js.map +0 -1
  148. package/dist/core/merge-manager.d.ts +0 -64
  149. package/dist/core/merge-manager.js +0 -111
  150. package/dist/core/merge-manager.js.map +0 -1
  151. package/dist/core/selection.d.ts +0 -31
  152. package/dist/core/selection.js +0 -62
  153. package/dist/core/selection.js.map +0 -1
  154. package/dist/core/sheet.d.ts +0 -116
  155. package/dist/core/sheet.js +0 -224
  156. package/dist/core/sheet.js.map +0 -1
  157. package/dist/core/workbook.d.ts +0 -43
  158. package/dist/core/workbook.js +0 -77
  159. package/dist/core/workbook.js.map +0 -1
  160. package/dist/grid/sheet-grid.d.ts +0 -81
  161. package/dist/grid/sheet-grid.js +0 -331
  162. package/dist/grid/sheet-grid.js.map +0 -1
  163. package/dist/grid/vtable-theme.js +0 -80
  164. package/dist/grid/vtable-theme.js.map +0 -1
  165. package/dist/tools/builtin.js.map +0 -1
  166. package/dist/tools/context.js.map +0 -1
  167. package/dist/tools/registry.js.map +0 -1
  168. package/dist/vue/index.d.ts +0 -2
  169. package/dist/vue/sheet.js.map +0 -1
  170. package/dist/vue/sheet.vue.d.ts +0 -24
  171. package/src/core/__test__/address.test.ts +0 -113
  172. package/src/core/__test__/cell-store.test.ts +0 -97
  173. package/src/core/__test__/command.test.ts +0 -377
  174. package/src/core/__test__/fill.test.ts +0 -140
  175. package/src/core/__test__/formula-adversarial.test.ts +0 -265
  176. package/src/core/__test__/formula-engine.test.ts +0 -470
  177. package/src/core/__test__/formula-functions.test.ts +0 -178
  178. package/src/core/__test__/formula-parser.test.ts +0 -160
  179. package/src/core/__test__/history.test.ts +0 -225
  180. package/src/core/__test__/merge-manager.test.ts +0 -106
  181. package/src/core/__test__/sheet.test.ts +0 -142
  182. package/src/core/address.ts +0 -166
  183. package/src/core/cell-store.ts +0 -173
  184. package/src/core/command/default-registry.ts +0 -15
  185. package/src/core/command/history.ts +0 -167
  186. package/src/core/command/index.ts +0 -7
  187. package/src/core/command/merge-cells.ts +0 -74
  188. package/src/core/command/registry.ts +0 -30
  189. package/src/core/command/set-cell-formula.ts +0 -29
  190. package/src/core/command/set-cell-value.ts +0 -38
  191. package/src/core/command/types.ts +0 -73
  192. package/src/core/events.ts +0 -37
  193. package/src/core/fill.ts +0 -314
  194. package/src/core/formula/ast.ts +0 -66
  195. package/src/core/formula/dependency-graph.ts +0 -329
  196. package/src/core/formula/errors.ts +0 -41
  197. package/src/core/formula/evaluator.ts +0 -219
  198. package/src/core/formula/functions.ts +0 -266
  199. package/src/core/formula/index.ts +0 -7
  200. package/src/core/formula/parser.ts +0 -198
  201. package/src/core/formula/tokenizer.ts +0 -160
  202. package/src/core/merge-manager.ts +0 -158
  203. package/src/core/selection.ts +0 -66
  204. package/src/core/sheet.ts +0 -282
  205. package/src/core/workbook.ts +0 -99
  206. package/src/grid/__test__/canvas-mock.ts +0 -49
  207. package/src/grid/__test__/setup.ts +0 -3
  208. package/src/grid/__test__/sheet-grid-formula.test.ts +0 -140
  209. package/src/grid/__test__/sheet-grid.test.ts +0 -275
  210. package/src/grid/sheet-grid.ts +0 -407
  211. package/src/grid/vtable-theme.ts +0 -61
@@ -1,7 +1,7 @@
1
- import { afterEach, describe, expect, it } from 'vitest'
1
+ import { Workbook } from '@veltra/sheet-core/core/workbook'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
3
  import { createApp, h, nextTick, ref, type App } from 'vue'
3
4
 
4
- import { Workbook } from '../../core/workbook'
5
5
  import { USheet } from '../../index'
6
6
  import { registerTool, unregisterTool } from '../../tools/registry'
7
7
  import type { SheetExposed } from '../../types'
@@ -38,12 +38,38 @@ function createWorkbook() {
38
38
  return workbook
39
39
  }
40
40
 
41
- function toolButton(el: HTMLElement, title: string): HTMLButtonElement | undefined {
42
- return [...el.querySelectorAll<HTMLButtonElement>('.u-sheet__tool')].find((button) =>
43
- button.textContent?.includes(title)
41
+ /** 按 data-tool-id / title 文本 / 原生 title 定位工具按钮(图标化后 textContent 可能为空) */
42
+ function toolButton(el: HTMLElement, idOrTitle: string): HTMLButtonElement | undefined {
43
+ return [...el.querySelectorAll<HTMLButtonElement>('.u-sheet__tool')].find(
44
+ (button) =>
45
+ button.dataset.toolId === idOrTitle ||
46
+ button.textContent?.includes(idOrTitle) ||
47
+ button.title.includes(idOrTitle)
44
48
  )
45
49
  }
46
50
 
51
+ /** 等待弹层打开:openPopup 走 setTimeout 宏任务(避免真实浏览器中同次 click 冒泡关闭面板) */
52
+ async function flushPopup(): Promise<void> {
53
+ await nextTick()
54
+ await new Promise((resolve) => setTimeout(resolve, 0))
55
+ await nextTick()
56
+ }
57
+
58
+ /** 等待查找防抖(use-find-replace 300ms 停键才全扫)+ 微任务 */
59
+ async function flushFind(): Promise<void> {
60
+ await new Promise((resolve) => setTimeout(resolve, 320))
61
+ await nextTick()
62
+ }
63
+
64
+ /**
65
+ * 等待工具栏状态落定:stateTick bump 走微任务合并(#25),一次 nextTick 只等到
66
+ * bump 微任务执行(Vue 渲染在其后的微任务),需要两个周期才能拿到新渲染结果。
67
+ */
68
+ async function flushState(): Promise<void> {
69
+ await nextTick()
70
+ await nextTick()
71
+ }
72
+
47
73
  function tabs(el: HTMLElement): HTMLButtonElement[] {
48
74
  return [...el.querySelectorAll<HTMLButtonElement>('.u-sheet__tab')]
49
75
  }
@@ -54,22 +80,95 @@ afterEach(() => {
54
80
  })
55
81
 
56
82
  describe('USheet 组件', () => {
57
- it('挂载:工具栏渲染内置工具(分组 + 分隔符),tabs 显示 sheet 列表,grid 挂载', async () => {
83
+ it('挂载后默认选区 A1:名称框显示 A1、fx 输入栏可用', async () => {
84
+ const workbook = createWorkbook()
85
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
86
+ await nextTick()
87
+
88
+ const nameBox = el.querySelector<HTMLInputElement>('.u-sheet__name-box')!
89
+ const fxInput = el.querySelector<HTMLTextAreaElement>('.u-sheet__fx-input')!
90
+ expect(nameBox.value).toBe('A1')
91
+ expect(fxInput.disabled).toBe(false)
92
+ expect(workbook.activeSheet.getSelection().activeCell).toEqual({ row: 0, col: 0 })
93
+ })
94
+
95
+ it('挂载:工具栏图标化分组(history|cell|text|edit|insert|file),tabs 与 grid 挂载', async () => {
58
96
  const { el } = mount(() => ({ workbook: createWorkbook(), rows: 10, cols: 6 }))
59
97
  await nextTick()
60
98
 
61
- const tools = [...el.querySelectorAll('.u-sheet__tool')].map((button) =>
62
- button.textContent?.trim()
99
+ const tools = [...el.querySelectorAll<HTMLButtonElement>('.u-sheet__tool')].map(
100
+ (button) => button.dataset.toolId
63
101
  )
64
- expect(tools).toEqual(['撤销', '重做', '合并', '取消合并'])
65
- // history 与 cell 两组之间一个分隔符
66
- expect(el.querySelectorAll('.u-sheet__toolbar-divider')).toHaveLength(1)
102
+ expect(tools).toEqual([
103
+ 'undo',
104
+ 'redo',
105
+ 'border',
106
+ 'fill-color',
107
+ 'merge',
108
+ 'unmerge',
109
+ 'bold',
110
+ 'italic',
111
+ 'underline',
112
+ 'strikethrough',
113
+ 'font-color',
114
+ 'font-size',
115
+ 'align-left',
116
+ 'align-center',
117
+ 'align-right',
118
+ 'valign-top',
119
+ 'valign-middle',
120
+ 'valign-bottom',
121
+ 'wrap-text',
122
+ 'find',
123
+ 'insert-image',
124
+ 'import',
125
+ 'export'
126
+ ])
127
+ // 六组之间五个分隔符
128
+ expect(el.querySelectorAll('.u-sheet__toolbar-divider')).toHaveLength(5)
129
+ // 图标化:每个内置工具有 icon,按钮无可见文字
130
+ for (const button of el.querySelectorAll<HTMLButtonElement>('.u-sheet__tool')) {
131
+ expect(button.querySelector('.u-sheet__tool-icon')).not.toBeNull()
132
+ expect(button.textContent?.trim()).toBe('')
133
+ expect(button.title.length).toBeGreaterThan(0)
134
+ }
135
+ // 已移除的工具不再出现
136
+ for (const id of [
137
+ 'insert-rows',
138
+ 'insert-cols',
139
+ 'delete-rows',
140
+ 'delete-cols',
141
+ 'freeze',
142
+ 'freeze-row',
143
+ 'freeze-col',
144
+ 'unfreeze',
145
+ 'export-xlsx',
146
+ 'export-csv'
147
+ ]) {
148
+ expect(toolButton(el, id)).toBeUndefined()
149
+ }
67
150
 
68
151
  expect(tabs(el).map((tab) => tab.textContent?.trim())).toEqual(['Sheet1', 'Sheet2'])
69
152
  expect(tabs(el)[0]!.classList.contains('is-active')).toBe(true)
70
153
 
71
154
  // VTable 已挂载到 grid 容器
72
155
  expect(el.querySelector('.u-sheet__grid canvas')).not.toBeNull()
156
+
157
+ // 单行滚动结构:工具按钮全部位于滚动视口内的 list 容器
158
+ const scroll = el.querySelector('.u-sheet__toolbar-scroll')
159
+ const list = el.querySelector('.u-sheet__toolbar-list')
160
+ expect(scroll).not.toBeNull()
161
+ expect(list).not.toBeNull()
162
+ expect(scroll!.querySelectorAll('.u-sheet__tool').length).toBeGreaterThan(0)
163
+ expect(list!.querySelectorAll('.u-sheet__tool').length).toBeGreaterThan(0)
164
+ })
165
+
166
+ it('工具栏无溢出时不渲染导航箭头(happy-dom 无布局 → 判定不溢出)', async () => {
167
+ const { el } = mount(() => ({ workbook: createWorkbook(), rows: 10, cols: 6 }))
168
+ await nextTick()
169
+
170
+ // happy-dom 下 clientWidth/scrollWidth 均为 0 → overflowing = false → 箭头不渲染
171
+ expect(el.querySelectorAll('.u-sheet__toolbar-nav')).toHaveLength(0)
73
172
  })
74
173
 
75
174
  it('内置 undo/redo 按钮随 history-change 置灰,点击等效快捷键', async () => {
@@ -78,24 +177,24 @@ describe('USheet 组件', () => {
78
177
  const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
79
178
  await nextTick()
80
179
 
81
- const undoButton = toolButton(el, '撤销')!
82
- const redoButton = toolButton(el, '重做')!
180
+ const undoButton = toolButton(el, 'undo')!
181
+ const redoButton = toolButton(el, 'redo')!
83
182
  expect(undoButton.disabled).toBe(true)
84
183
  expect(redoButton.disabled).toBe(true)
85
184
 
86
185
  sheet.setCellValue({ row: 0, col: 0 }, 'x')
87
- await nextTick()
186
+ await flushState()
88
187
  expect(undoButton.disabled).toBe(false)
89
188
  expect(redoButton.disabled).toBe(true)
90
189
 
91
190
  undoButton.click()
92
- await nextTick()
191
+ await flushState()
93
192
  expect(sheet.getCellData({ row: 0, col: 0 })).toBeUndefined()
94
193
  expect(undoButton.disabled).toBe(true)
95
194
  expect(redoButton.disabled).toBe(false)
96
195
 
97
196
  redoButton.click()
98
- await nextTick()
197
+ await flushState()
99
198
  expect(sheet.getCellData({ row: 0, col: 0 })).toEqual({ v: 'x', t: 's' })
100
199
  })
101
200
 
@@ -112,8 +211,12 @@ describe('USheet 组件', () => {
112
211
  await nextTick()
113
212
 
114
213
  expect(tabs(el)[1]!.classList.contains('is-active')).toBe(true)
115
- // grid 重建:旧 VTable 已 release,容器内只有一个 canvas
116
- expect(el.querySelectorAll('.u-sheet__grid canvas')).toHaveLength(1)
214
+ // grid 实例缓存:切换后旧实例保留(LRU,visibility:hidden),容器内有 2 个 canvas
215
+ expect(el.querySelectorAll('.u-sheet__grid canvas')).toHaveLength(2)
216
+ // 激活实例可见、旧实例隐藏
217
+ const instances = el.querySelectorAll<HTMLElement>('.u-sheet__grid-instance')
218
+ expect(instances[0]!.style.visibility).toBe('hidden')
219
+ expect(instances[1]!.style.visibility).toBe('visible')
117
220
  expect(exposed.value?.getActiveSheet().name).toBe('Sheet2')
118
221
  // 工具上下文(与工具按钮同一门面)指向切换后的 sheet
119
222
  expect(exposed.value?.getContext().sheetName).toBe('Sheet2')
@@ -173,15 +276,17 @@ describe('USheet 组件', () => {
173
276
  it('注册表联动:运行时 register/unregister 工具即时增删按钮', async () => {
174
277
  const { el } = mount(() => ({ workbook: createWorkbook(), rows: 10, cols: 6 }))
175
278
  await nextTick()
176
- expect(toolButton(el, '临时工具')).toBeUndefined()
279
+ expect(toolButton(el, 'temp-tool')).toBeUndefined()
177
280
 
178
281
  registerTool({ id: 'temp-tool', title: '临时工具', onClick: () => {} })
179
282
  await nextTick()
180
- expect(toolButton(el, '临时工具')).toBeDefined()
283
+ expect(toolButton(el, 'temp-tool')).toBeDefined()
284
+ // 无 icon 时回落文字按钮
285
+ expect(toolButton(el, 'temp-tool')!.textContent?.trim()).toBe('临时工具')
181
286
 
182
287
  expect(unregisterTool('temp-tool')).toBe(true)
183
288
  await nextTick()
184
- expect(toolButton(el, '临时工具')).toBeUndefined()
289
+ expect(toolButton(el, 'temp-tool')).toBeUndefined()
185
290
  })
186
291
 
187
292
  it('无 props 默认挂载:内部自建工作簿(单 sheet),工具栏/tabs 正常', async () => {
@@ -192,6 +297,37 @@ describe('USheet 组件', () => {
192
297
  expect(el.querySelector('.u-sheet__grid canvas')).not.toBeNull()
193
298
  })
194
299
 
300
+ it('tab「+」按钮:添加 sheet 并自动激活新表', async () => {
301
+ const workbook = createWorkbook()
302
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
303
+ await nextTick()
304
+
305
+ const addButton = el.querySelector<HTMLButtonElement>('.u-sheet__tab-add')!
306
+ addButton.click()
307
+ await nextTick()
308
+
309
+ expect(workbook.sheetCount).toBe(3)
310
+ expect(workbook.activeSheet.name).toBe('Sheet3')
311
+ expect(tabs(el).map((tab) => tab.textContent?.trim())).toEqual(['Sheet1', 'Sheet2', 'Sheet3'])
312
+ // 新表自动激活:active 样式落在新 tab 上
313
+ expect(tabs(el)[2]!.classList.contains('is-active')).toBe(true)
314
+ })
315
+
316
+ it('renameSheet 后 tab 文本跟随更新', async () => {
317
+ const workbook = createWorkbook()
318
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
319
+ await nextTick()
320
+
321
+ expect(workbook.renameSheet('Sheet2', '数据表')).toBe(true)
322
+ await nextTick()
323
+ expect(tabs(el).map((tab) => tab.textContent?.trim())).toEqual(['Sheet1', '数据表'])
324
+
325
+ // 重名(大小写不敏感)被拒绝:tab 文本不变
326
+ expect(workbook.renameSheet('Sheet1', '数据表')).toBe(false)
327
+ await nextTick()
328
+ expect(tabs(el).map((tab) => tab.textContent?.trim())).toEqual(['Sheet1', '数据表'])
329
+ })
330
+
195
331
  it('多实例并存:共享注册表,工具点击只作用于各自实例的活动 sheet', async () => {
196
332
  const workbook1 = new Workbook()
197
333
  const workbook2 = new Workbook()
@@ -210,8 +346,8 @@ describe('USheet 组件', () => {
210
346
  await nextTick()
211
347
 
212
348
  // 两个实例都渲染该工具(注册表全局共享)
213
- const button1 = toolButton(first.el, '标记当前格')
214
- const button2 = toolButton(second.el, '标记当前格')
349
+ const button1 = toolButton(first.el, 'mark-active')
350
+ const button2 = toolButton(second.el, 'mark-active')
215
351
  expect(button1).toBeDefined()
216
352
  expect(button2).toBeDefined()
217
353
 
@@ -237,15 +373,15 @@ describe('USheet 组件', () => {
237
373
  const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
238
374
  await nextTick()
239
375
 
240
- const mergeButton = toolButton(el, '合并')!
241
- const unmergeButton = toolButton(el, '取消合并')!
242
- // 无选区:两者禁用
376
+ const mergeButton = toolButton(el, 'merge')!
377
+ const unmergeButton = toolButton(el, 'unmerge')!
378
+ // 默认单格 A1:合并禁用;无合并 → 取消合并禁用
243
379
  expect(mergeButton.disabled).toBe(true)
244
380
  expect(unmergeButton.disabled).toBe(true)
245
381
 
246
382
  // 拖选区域(经模型 API 模拟 grid 拖选回写)
247
383
  sheet.selectRange({ start: { row: 0, col: 0 }, end: { row: 1, col: 1 } })
248
- await nextTick()
384
+ await flushState()
249
385
  expect(mergeButton.disabled).toBe(false)
250
386
 
251
387
  mergeButton.click()
@@ -264,4 +400,557 @@ describe('USheet 组件', () => {
264
400
  sheet.undo()
265
401
  expect(sheet.getCellInfo({ row: 1, col: 1 }).kind).toBe('merged-covered')
266
402
  })
403
+
404
+ it('弹层工具:点击打开面板(UPalette 渲染),点击面板外关闭并提交事务', async () => {
405
+ const workbook = createWorkbook()
406
+ const sheet = workbook.activeSheet
407
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
408
+ await nextTick()
409
+
410
+ // 默认选区 A1:填充可用
411
+ const fillButton = toolButton(el, 'fill-color')!
412
+ expect(fillButton.disabled).toBe(false)
413
+
414
+ fillButton.click()
415
+ await flushPopup()
416
+ // 面板已渲染(含 UPalette 取色器)
417
+ const popupEl = document.querySelector<HTMLElement>('#pop-container .u-sheet__popup')
418
+ expect(popupEl).not.toBeNull()
419
+ expect(document.querySelector('#pop-container .u-sheet__popup .u-palette')).not.toBeNull()
420
+ // 面板 Teleport 到 body 级 #pop-container(不在 mount 容器内,定位由 usePop 接管)
421
+ expect(el.querySelector('.u-sheet__popup')).toBeNull()
422
+
423
+ // 点击面板外(grid 区)→ 关闭;空事务不入历史
424
+ el.querySelector('.u-sheet__grid')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
425
+ await nextTick()
426
+ expect(document.querySelector('#pop-container .u-sheet__popup')).toBeNull()
427
+ expect(sheet.history.undoSize).toBe(0)
428
+ })
429
+
430
+ it('文本工具:加粗 toggle + active 高亮;字体颜色弹层可打开', async () => {
431
+ const workbook = createWorkbook()
432
+ const sheet = workbook.activeSheet
433
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
434
+ await nextTick()
435
+
436
+ const boldButton = toolButton(el, 'bold')!
437
+ expect(boldButton.classList.contains('is-active')).toBe(false)
438
+ boldButton.click()
439
+ await flushState()
440
+ expect(sheet.getCellStyle({ row: 0, col: 0 })?.font?.bold).toBe(true)
441
+ expect(boldButton.classList.contains('is-active')).toBe(true)
442
+ boldButton.click()
443
+ await flushState()
444
+ expect(sheet.getCellStyle({ row: 0, col: 0 })).toBeUndefined()
445
+ expect(boldButton.classList.contains('is-active')).toBe(false)
446
+
447
+ toolButton(el, 'font-color')!.click()
448
+ await flushPopup()
449
+ expect(document.querySelector('#pop-container .u-sheet__popup .u-palette')).not.toBeNull()
450
+ el.querySelector('.u-sheet__grid')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
451
+ await nextTick()
452
+ expect(document.querySelector('#pop-container .u-sheet__popup')).toBeNull()
453
+ })
454
+
455
+ it('字号弹层:预设写入 + 关闭提交为一个 undo 单元', async () => {
456
+ const workbook = createWorkbook()
457
+ const sheet = workbook.activeSheet
458
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
459
+ await nextTick()
460
+
461
+ toolButton(el, 'font-size')!.click()
462
+ await flushPopup()
463
+ const sizeButtons = [
464
+ ...document.querySelectorAll<HTMLButtonElement>('#pop-container .u-sheet__popup-size-item')
465
+ ]
466
+ expect(sizeButtons.map((button) => button.textContent?.trim())).toContain('14')
467
+ sizeButtons.find((button) => button.textContent?.trim() === '14')!.click()
468
+ await nextTick()
469
+ expect(sheet.getCellStyle({ row: 0, col: 0 })?.font?.size).toBe(14)
470
+
471
+ el.querySelector('.u-sheet__grid')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
472
+ await nextTick()
473
+ expect(document.querySelector('#pop-container .u-sheet__popup')).toBeNull()
474
+ expect(sheet.history.undoSize).toBe(1)
475
+ sheet.undo()
476
+ expect(sheet.getCellStyle({ row: 0, col: 0 })).toBeUndefined()
477
+ })
478
+
479
+ it('边框面板:预设应用 + 关闭提交为一个 undo 单元', async () => {
480
+ const workbook = createWorkbook()
481
+ const sheet = workbook.activeSheet
482
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
483
+ await nextTick()
484
+
485
+ sheet.selectRange({ start: { row: 0, col: 0 }, end: { row: 1, col: 1 } })
486
+ await nextTick()
487
+ toolButton(el, 'border')!.click()
488
+ await flushPopup()
489
+
490
+ const presetButtons = [...document.querySelectorAll('#pop-container .u-sheet__popup-preset')]
491
+ expect(presetButtons.map((button) => (button as HTMLElement).title)).toEqual([
492
+ '外边框',
493
+ '内边框',
494
+ '所有边框',
495
+ '上边框',
496
+ '下边框',
497
+ '左边框',
498
+ '右边框',
499
+ '无边框'
500
+ ])
501
+
502
+ presetButtons.find((button) => (button as HTMLElement).title === '所有边框')!.click()
503
+ await nextTick()
504
+ // 选区内每格四边都有边框
505
+ expect(sheet.getCellStyle({ row: 0, col: 0 })?.border?.top).toBeDefined()
506
+ expect(sheet.getCellStyle({ row: 1, col: 1 })?.border?.bottom).toBeDefined()
507
+ expect(sheet.getCellStyle({ row: 0, col: 1 })?.border?.left).toBeDefined()
508
+
509
+ // 无边框 → 清除边框
510
+ presetButtons.find((button) => (button as HTMLElement).title === '无边框')!.click()
511
+ await nextTick()
512
+ expect(sheet.getCellStyle({ row: 0, col: 0 })?.border).toBeUndefined()
513
+
514
+ // 点击外部关闭 → 面板期间所有写入合并为一个 undo 单元
515
+ el.querySelector('.u-sheet__grid')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
516
+ await nextTick()
517
+ expect(document.querySelector('#pop-container .u-sheet__popup')).toBeNull()
518
+ expect(sheet.history.undoSize).toBe(1)
519
+
520
+ sheet.undo()
521
+ expect(sheet.getCellStyle({ row: 0, col: 0 })).toBeUndefined()
522
+ })
523
+
524
+ it('冻结:模型 setFrozen 驱动 VTable 冻结布局联动(工具栏入口已迁至右键菜单)', async () => {
525
+ const workbook = createWorkbook()
526
+ const sheet = workbook.activeSheet
527
+ const exposed: { value: SheetExposed | undefined } = { value: undefined }
528
+ mount(() => ({ workbook, rows: 10, cols: 6 }), exposed)
529
+ await nextTick()
530
+
531
+ sheet.setFrozen(1, 0)
532
+ await nextTick()
533
+ expect(sheet.frozen).toEqual({ rows: 1, cols: 0 })
534
+ // 模型 1 行 → frozenRowCount = 2:列头行 + 首行;列保持 1:行号列
535
+ expect(exposed.value?.getGrid()?.getTable().frozenRowCount).toBe(2)
536
+ expect(exposed.value?.getGrid()?.getTable().frozenColCount).toBe(1)
537
+
538
+ sheet.setFrozen(1, 1)
539
+ await nextTick()
540
+ expect(exposed.value?.getGrid()?.getTable().frozenColCount).toBe(2)
541
+
542
+ sheet.setFrozen(0, 0)
543
+ await nextTick()
544
+ expect(sheet.frozen).toEqual({ rows: 0, cols: 0 })
545
+ })
546
+
547
+ it('导入:点击直接拉起系统文件选择,不打开弹层、不产生历史条目', async () => {
548
+ const workbook = createWorkbook()
549
+ const sheet = workbook.activeSheet
550
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
551
+ await nextTick()
552
+
553
+ const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
554
+ try {
555
+ toolButton(el, 'import')!.click()
556
+ await nextTick()
557
+ expect(document.querySelector('#pop-container .u-sheet__popup')).toBeNull()
558
+ const input = document.body.querySelector<HTMLInputElement>('input[type="file"]')
559
+ expect(input).toBeTruthy()
560
+ expect(input!.accept).toBe('.xlsx,.csv')
561
+ expect(input!.hidden).toBe(true)
562
+ expect(click).toHaveBeenCalled()
563
+ expect(sheet.history.undoSize).toBe(0)
564
+ } finally {
565
+ click.mockRestore()
566
+ document.body.querySelectorAll('input[type="file"]').forEach((node) => node.remove())
567
+ }
568
+ })
569
+
570
+ it('导出弹层:单按钮打开两选项;点击选项触发下载并关闭,不产生历史', async () => {
571
+ const workbook = createWorkbook()
572
+ const sheet = workbook.activeSheet
573
+ sheet.setCellValue({ row: 0, col: 0 }, 1)
574
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
575
+ await nextTick()
576
+
577
+ const originalCreate = URL.createObjectURL
578
+ const originalRevoke = URL.revokeObjectURL
579
+ const createObjectURL = vi.fn(() => 'blob:mock')
580
+ URL.createObjectURL = createObjectURL as typeof URL.createObjectURL
581
+ URL.revokeObjectURL = vi.fn() as typeof URL.revokeObjectURL
582
+ try {
583
+ toolButton(el, 'export')!.click()
584
+ await flushPopup()
585
+ const options = [
586
+ ...document.querySelectorAll<HTMLButtonElement>('#pop-container .u-sheet__export-option')
587
+ ]
588
+ expect(options.map((button) => button.textContent?.trim())).toEqual([
589
+ '导出 Excel (.xlsx)',
590
+ '导出 CSV (.csv)'
591
+ ])
592
+
593
+ options[0]!.click()
594
+ await nextTick()
595
+ await vi.waitFor(() => expect(createObjectURL).toHaveBeenCalled(), { timeout: 2000 })
596
+ expect(document.querySelector('#pop-container .u-sheet__popup')).toBeNull()
597
+ expect(sheet.history.undoSize).toBe(1) // 预置 setCellValue;导出本身不写模型
598
+
599
+ toolButton(el, 'export')!.click()
600
+ await flushPopup()
601
+ createObjectURL.mockClear()
602
+ document
603
+ .querySelectorAll<HTMLButtonElement>('#pop-container .u-sheet__export-option')[1]!
604
+ .click()
605
+ await nextTick()
606
+ await vi.waitFor(() => expect(createObjectURL).toHaveBeenCalled(), { timeout: 2000 })
607
+ const csvBlob = createObjectURL.mock.calls[0]![0] as Blob
608
+ expect(csvBlob.type).toContain('text/csv')
609
+ expect(document.querySelector('#pop-container .u-sheet__popup')).toBeNull()
610
+ } finally {
611
+ URL.createObjectURL = originalCreate
612
+ URL.revokeObjectURL = originalRevoke
613
+ }
614
+ })
615
+
616
+ it('查找条:打开 → 关键词定位命中 → Enter 下一个 / Shift+Enter 上一个 → 关闭', async () => {
617
+ const workbook = createWorkbook()
618
+ const sheet = workbook.activeSheet
619
+ sheet.setCellValue({ row: 0, col: 0 }, 'hello')
620
+ sheet.setCellValue({ row: 0, col: 1 }, 'world')
621
+ sheet.setCellValue({ row: 1, col: 0 }, 'hello again')
622
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
623
+ await nextTick()
624
+
625
+ toolButton(el, 'find')!.click()
626
+ await flushPopup()
627
+ expect(document.querySelector('#pop-container .u-sheet__popup')).not.toBeNull()
628
+ expect(document.querySelector('#pop-container .u-sheet__find-input')).not.toBeNull()
629
+
630
+ // 输入关键词 → 定位第一个命中(A1)
631
+ const input = document.querySelector<HTMLInputElement>(
632
+ '#pop-container .u-sheet__find-input .u-input__native'
633
+ )!
634
+ input.value = 'hello'
635
+ input.dispatchEvent(new Event('input', { bubbles: true }))
636
+ await flushFind()
637
+ expect(sheet.getSelection().activeCell).toEqual({ row: 0, col: 0 })
638
+ expect(document.querySelector('#pop-container .u-sheet__find-count')!.textContent?.trim()).toBe(
639
+ '1 / 2'
640
+ )
641
+
642
+ // Enter → 下一个命中(A3)
643
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
644
+ await nextTick()
645
+ expect(sheet.getSelection().activeCell).toEqual({ row: 1, col: 0 })
646
+ expect(document.querySelector('#pop-container .u-sheet__find-count')!.textContent?.trim()).toBe(
647
+ '2 / 2'
648
+ )
649
+
650
+ // Enter 循环回第一个
651
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
652
+ await nextTick()
653
+ expect(sheet.getSelection().activeCell).toEqual({ row: 0, col: 0 })
654
+
655
+ // Shift+Enter → 上一个(循环回最后一个)
656
+ input.dispatchEvent(
657
+ new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true })
658
+ )
659
+ await nextTick()
660
+ expect(sheet.getSelection().activeCell).toEqual({ row: 1, col: 0 })
661
+
662
+ // 无命中 → 计数 0 / 0,导航禁用
663
+ input.value = '不存在'
664
+ input.dispatchEvent(new Event('input', { bubbles: true }))
665
+ await flushFind()
666
+ expect(document.querySelector('#pop-container .u-sheet__find-count')!.textContent?.trim()).toBe(
667
+ '0 / 0'
668
+ )
669
+ expect(
670
+ document.querySelector<HTMLButtonElement>('#pop-container .u-sheet__find-nav')!.disabled
671
+ ).toBe(true)
672
+
673
+ // 关闭
674
+ document.querySelector<HTMLButtonElement>('#pop-container .u-sheet__find-close')!.click()
675
+ await nextTick()
676
+ expect(document.querySelector('#pop-container .u-sheet__popup')).toBeNull()
677
+ })
678
+
679
+ it('替换:单个替换可 undo;全部替换 = 单 undo 单元,undo 一次全部还原', async () => {
680
+ const workbook = createWorkbook()
681
+ const sheet = workbook.activeSheet
682
+ sheet.setCellValue({ row: 0, col: 0 }, 'foo')
683
+ sheet.setCellValue({ row: 0, col: 1 }, 'foo')
684
+ sheet.setCellValue({ row: 1, col: 0 }, 'foo')
685
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
686
+ await nextTick()
687
+
688
+ toolButton(el, 'find')!.click()
689
+ await flushPopup()
690
+
691
+ const queryInput = document.querySelector<HTMLInputElement>(
692
+ '#pop-container .u-sheet__find-input .u-input__native'
693
+ )!
694
+ queryInput.value = 'foo'
695
+ queryInput.dispatchEvent(new Event('input', { bubbles: true }))
696
+ await flushFind()
697
+ expect(document.querySelector('#pop-container .u-sheet__find-count')!.textContent?.trim()).toBe(
698
+ '1 / 3'
699
+ )
700
+
701
+ // 全部替换:3 格一次写入
702
+ const replaceInputs = [
703
+ ...document.querySelectorAll('#pop-container .u-sheet__find-input .u-input__native')
704
+ ]
705
+ replaceInputs[1]!.value = 'bar'
706
+ replaceInputs[1]!.dispatchEvent(new Event('input', { bubbles: true }))
707
+ await nextTick()
708
+ document.querySelectorAll('#pop-container .u-sheet__find-btn')[1]!.click()
709
+ await nextTick()
710
+
711
+ expect(sheet.getCellData({ row: 0, col: 0 })).toMatchObject({ v: 'bar' })
712
+ expect(sheet.getCellData({ row: 0, col: 1 })).toMatchObject({ v: 'bar' })
713
+ expect(sheet.getCellData({ row: 1, col: 0 })).toMatchObject({ v: 'bar' })
714
+ // 全部替换 = 单 undo 单元(3 次 setCellValue 初始 + 1 次替换)
715
+ expect(sheet.history.undoSize).toBe(4)
716
+
717
+ // undo 一次 → 全部还原
718
+ sheet.undo()
719
+ expect(sheet.getCellData({ row: 0, col: 0 })).toMatchObject({ v: 'foo' })
720
+ expect(sheet.getCellData({ row: 0, col: 1 })).toMatchObject({ v: 'foo' })
721
+ expect(sheet.getCellData({ row: 1, col: 0 })).toMatchObject({ v: 'foo' })
722
+ })
723
+
724
+ it('全部替换跳过公式格(不覆盖公式原文)', async () => {
725
+ const workbook = createWorkbook()
726
+ const sheet = workbook.activeSheet
727
+ sheet.setCellValue({ row: 0, col: 0 }, 'foo')
728
+ sheet.setCellFormula({ row: 1, col: 0 }, '=A1&"!"') // 公式格显示值 foo!
729
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
730
+ await nextTick()
731
+
732
+ toolButton(el, 'find')!.click()
733
+ await flushPopup()
734
+
735
+ const queryInput = document.querySelector<HTMLInputElement>(
736
+ '#pop-container .u-sheet__find-input .u-input__native'
737
+ )!
738
+ queryInput.value = 'foo'
739
+ queryInput.dispatchEvent(new Event('input', { bubbles: true }))
740
+ await flushFind()
741
+ // 普通格 + 公式格显示值都命中
742
+ expect(document.querySelector('#pop-container .u-sheet__find-count')!.textContent?.trim()).toBe(
743
+ '1 / 2'
744
+ )
745
+
746
+ const replaceInputs = [
747
+ ...document.querySelectorAll('#pop-container .u-sheet__find-input .u-input__native')
748
+ ]
749
+ replaceInputs[1]!.value = 'bar'
750
+ replaceInputs[1]!.dispatchEvent(new Event('input', { bubbles: true }))
751
+ await nextTick()
752
+ document.querySelectorAll('#pop-container .u-sheet__find-btn')[1]!.click()
753
+ await nextTick()
754
+
755
+ // 普通格被替换,公式格保留公式(显示值也因依赖 A1 变化为 bar!)
756
+ expect(sheet.getCellData({ row: 0, col: 0 })).toMatchObject({ v: 'bar' })
757
+ expect(sheet.getCellData({ row: 1, col: 0 })).toMatchObject({ f: 'A1&"!"' })
758
+ expect(sheet.getCellData({ row: 1, col: 0 })?.v).toBe('bar!')
759
+ })
760
+
761
+ it('防抖窗口内全部替换:按输入框当前关键词补扫,不用旧缓存的命中集', async () => {
762
+ const workbook = createWorkbook()
763
+ const sheet = workbook.activeSheet
764
+ sheet.setCellValue({ row: 0, col: 0 }, 'foo')
765
+ sheet.setCellValue({ row: 0, col: 1 }, 'f')
766
+ sheet.setCellValue({ row: 1, col: 0 }, 'f')
767
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
768
+ await nextTick()
769
+
770
+ toolButton(el, 'find')!.click()
771
+ await flushPopup()
772
+
773
+ const queryInput = document.querySelector<HTMLInputElement>(
774
+ '#pop-container .u-sheet__find-input .u-input__native'
775
+ )!
776
+ queryInput.value = 'foo'
777
+ queryInput.dispatchEvent(new Event('input', { bubbles: true }))
778
+ await flushFind()
779
+ expect(document.querySelector('#pop-container .u-sheet__find-count')!.textContent?.trim()).toBe(
780
+ '1 / 1'
781
+ )
782
+
783
+ // 快速改输 'f'(不等 300ms 防抖触发)→ 立刻全部替换。
784
+ // 'f' 命中全部 3 格(foo 含 f);若误用 'foo' 的旧缓存只会改写 A1
785
+ queryInput.value = 'f'
786
+ queryInput.dispatchEvent(new Event('input', { bubbles: true }))
787
+ await nextTick()
788
+
789
+ const replaceInputs = [
790
+ ...document.querySelectorAll('#pop-container .u-sheet__find-input .u-input__native')
791
+ ]
792
+ replaceInputs[1]!.value = 'x'
793
+ replaceInputs[1]!.dispatchEvent(new Event('input', { bubbles: true }))
794
+ await nextTick()
795
+ document.querySelectorAll('#pop-container .u-sheet__find-btn')[1]!.click()
796
+ await nextTick()
797
+
798
+ expect(sheet.getCellData({ row: 0, col: 0 })).toMatchObject({ v: 'x' })
799
+ expect(sheet.getCellData({ row: 0, col: 1 })).toMatchObject({ v: 'x' })
800
+ expect(sheet.getCellData({ row: 1, col: 0 })).toMatchObject({ v: 'x' })
801
+ })
802
+
803
+ it('Ctrl+F:容器外按键不劫持浏览器查找;查找条聚焦时(弹层在 root 外)可 toggle 关闭', async () => {
804
+ const workbook = createWorkbook()
805
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
806
+ await nextTick()
807
+
808
+ // 容器外(body)按 Ctrl+F:不弹查找条、不 preventDefault
809
+ const outside = new KeyboardEvent('keydown', {
810
+ key: 'f',
811
+ ctrlKey: true,
812
+ bubbles: true,
813
+ cancelable: true
814
+ })
815
+ document.body.dispatchEvent(outside)
816
+ await flushPopup()
817
+ expect(outside.defaultPrevented).toBe(false)
818
+ expect(document.querySelector('#pop-container .u-sheet__popup')).toBeNull()
819
+
820
+ // 打开查找条,焦点进入弹层输入框(Teleport 到 #pop-container,不在实例 root 内)
821
+ toolButton(el, 'find')!.click()
822
+ await flushPopup()
823
+ const input = document.querySelector<HTMLInputElement>(
824
+ '#pop-container .u-sheet__find-input .u-input__native'
825
+ )!
826
+ input.focus()
827
+
828
+ // 弹层内再按 Ctrl+F:toggle 关闭查找条,且拦截浏览器原生查找
829
+ const toggle = new KeyboardEvent('keydown', {
830
+ key: 'f',
831
+ ctrlKey: true,
832
+ bubbles: true,
833
+ cancelable: true
834
+ })
835
+ input.dispatchEvent(toggle)
836
+ await nextTick()
837
+ expect(toggle.defaultPrevented).toBe(true)
838
+ expect(document.querySelector('#pop-container .u-sheet__popup')).toBeNull()
839
+ })
840
+ })
841
+
842
+ describe('tabs 栏', () => {
843
+ it('少量 sheet:「+」在视口外且紧随 viewport(nav 隐藏,不占位)', async () => {
844
+ const workbook = createWorkbook()
845
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
846
+ await nextTick()
847
+
848
+ const tabsBar = el.querySelector<HTMLElement>('.u-sheet__tabs')!
849
+ const viewport = el.querySelector<HTMLElement>('.u-sheet__tabs-viewport')!
850
+ const addButton = el.querySelector<HTMLButtonElement>('.u-sheet__tab-add')!
851
+ const navButtons = [...el.querySelectorAll<HTMLButtonElement>('.u-sheet__tabs-nav')]
852
+
853
+ // 「+」仍是 tabs 直接子级,不在滚动 list 内
854
+ expect(tabsBar.contains(addButton)).toBe(true)
855
+ expect(viewport.contains(addButton)).toBe(false)
856
+ expect(el.querySelector('.u-sheet__tabs-list .u-sheet__tab-add')).toBeNull()
857
+
858
+ // 未溢出:nav 隐藏;DOM 顺序 viewport → next-nav →「+」,布局上「+」紧跟 tab 区
859
+ expect(navButtons).toHaveLength(2)
860
+ expect(navButtons[0]!.style.display).toBe('none')
861
+ expect(navButtons[1]!.style.display).toBe('none')
862
+ expect(viewport.nextElementSibling).toBe(navButtons[1]!)
863
+ expect(navButtons[1]!.nextElementSibling).toBe(addButton)
864
+ })
865
+
866
+ it('多 sheet 溢出:viewport 可横滚,nav 出现;next/prev 改变 scrollLeft;活动 tab 滚入视野', async () => {
867
+ const workbook = new Workbook()
868
+ for (let i = 2; i <= 16; i++) workbook.addSheet(`Sheet${i}`)
869
+ const { el } = mount(() => ({ workbook, rows: 10, cols: 6 }))
870
+ await nextTick()
871
+
872
+ const viewport = el.querySelector<HTMLElement>('.u-sheet__tabs-viewport')!
873
+ const list = el.querySelector<HTMLElement>('.u-sheet__tabs-list')!
874
+ expect(viewport).not.toBeNull()
875
+ expect(list).not.toBeNull()
876
+ expect(list.children.length).toBe(16)
877
+ // 「+」钉在视口外(tabs 直接子级,不在 list 内)
878
+ expect(el.querySelector('.u-sheet__tabs > .u-sheet__tab-add')).not.toBeNull()
879
+ expect(list.querySelector('.u-sheet__tab-add')).toBeNull()
880
+
881
+ // happy-dom 无真实布局:伪造溢出几何,触发导航显隐
882
+ let scrollLeft = 0
883
+ Object.defineProperty(viewport, 'clientWidth', { configurable: true, get: () => 100 })
884
+ Object.defineProperty(viewport, 'scrollWidth', { configurable: true, get: () => 800 })
885
+ Object.defineProperty(viewport, 'scrollLeft', {
886
+ configurable: true,
887
+ get: () => scrollLeft,
888
+ set: (value: number) => {
889
+ scrollLeft = value
890
+ }
891
+ })
892
+ viewport.scrollTo = ((options?: ScrollToOptions | number) => {
893
+ if (typeof options === 'number') {
894
+ scrollLeft = options
895
+ } else if (options && typeof options.left === 'number') {
896
+ scrollLeft = options.left
897
+ }
898
+ viewport.dispatchEvent(new Event('scroll'))
899
+ }) as typeof viewport.scrollTo
900
+ viewport.getBoundingClientRect = () =>
901
+ ({
902
+ left: 0,
903
+ right: 100,
904
+ top: 0,
905
+ bottom: 24,
906
+ width: 100,
907
+ height: 24,
908
+ x: 0,
909
+ y: 0,
910
+ toJSON: () => ({})
911
+ }) as DOMRect
912
+
913
+ viewport.dispatchEvent(new Event('scroll'))
914
+ await nextTick()
915
+
916
+ expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth)
917
+ const navButtons = [...el.querySelectorAll<HTMLButtonElement>('.u-sheet__tabs-nav')]
918
+ expect(navButtons).toHaveLength(2)
919
+ // v-show:溢出时显示;初始 scrollLeft=0 → prev 禁用、next 可用
920
+ expect(navButtons[0]!.style.display).not.toBe('none')
921
+ expect(navButtons[1]!.style.display).not.toBe('none')
922
+ expect(navButtons[0]!.disabled).toBe(true)
923
+ expect(navButtons[1]!.disabled).toBe(false)
924
+
925
+ navButtons[1]!.click()
926
+ await nextTick()
927
+ expect(scrollLeft).toBeGreaterThan(0)
928
+ expect(navButtons[0]!.disabled).toBe(false)
929
+
930
+ const beforePrev = scrollLeft
931
+ navButtons[0]!.click()
932
+ await nextTick()
933
+ expect(scrollLeft).toBeLessThan(beforePrev)
934
+
935
+ // 切换到末尾 sheet:活动 tab 在视口右侧外 → ensureActiveVisible 滚入
936
+ const lastTab = tabs(el)[15]!
937
+ lastTab.getBoundingClientRect = () =>
938
+ ({
939
+ left: 700,
940
+ right: 780,
941
+ top: 0,
942
+ bottom: 24,
943
+ width: 80,
944
+ height: 24,
945
+ x: 700,
946
+ y: 0,
947
+ toJSON: () => ({})
948
+ }) as DOMRect
949
+ scrollLeft = 0
950
+ lastTab.click()
951
+ await nextTick()
952
+ await nextTick()
953
+ expect(workbook.activeSheet.name).toBe('Sheet16')
954
+ expect(scrollLeft).toBeGreaterThan(0)
955
+ })
267
956
  })