@bagelink/vue 1.10.1 → 1.10.3

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 (34) hide show
  1. package/dist/components/Spreadsheet/Index.vue.d.ts +3 -22
  2. package/dist/components/Spreadsheet/Index.vue.d.ts.map +1 -1
  3. package/dist/components/Spreadsheet/SpreadsheetCell.vue.d.ts +23 -0
  4. package/dist/components/Spreadsheet/SpreadsheetCell.vue.d.ts.map +1 -0
  5. package/dist/components/Spreadsheet/SpreadsheetTable.vue.d.ts +2 -15
  6. package/dist/components/Spreadsheet/SpreadsheetTable.vue.d.ts.map +1 -1
  7. package/dist/components/Spreadsheet/composables/useSpreadsheetClipboard.d.ts +7 -0
  8. package/dist/components/Spreadsheet/composables/useSpreadsheetClipboard.d.ts.map +1 -0
  9. package/dist/components/Spreadsheet/composables/useSpreadsheetColumns.d.ts +14 -0
  10. package/dist/components/Spreadsheet/composables/useSpreadsheetColumns.d.ts.map +1 -0
  11. package/dist/components/Spreadsheet/composables/useSpreadsheetSelection.d.ts +24 -0
  12. package/dist/components/Spreadsheet/composables/useSpreadsheetSelection.d.ts.map +1 -0
  13. package/dist/components/Spreadsheet/composables/useSpreadsheetSort.d.ts +8 -0
  14. package/dist/components/Spreadsheet/composables/useSpreadsheetSort.d.ts.map +1 -0
  15. package/dist/components/Spreadsheet/composables/useSpreadsheetUndo.d.ts +72 -0
  16. package/dist/components/Spreadsheet/composables/useSpreadsheetUndo.d.ts.map +1 -0
  17. package/dist/components/Spreadsheet/types.d.ts +36 -0
  18. package/dist/components/Spreadsheet/types.d.ts.map +1 -0
  19. package/dist/components/Spreadsheet/utils.d.ts +6 -0
  20. package/dist/components/Spreadsheet/utils.d.ts.map +1 -0
  21. package/dist/index.cjs +90 -90
  22. package/dist/index.mjs +10165 -10102
  23. package/dist/style.css +1 -1
  24. package/package.json +2 -2
  25. package/src/components/Spreadsheet/Index.vue +127 -619
  26. package/src/components/Spreadsheet/SpreadsheetCell.vue +56 -0
  27. package/src/components/Spreadsheet/SpreadsheetTable.vue +79 -77
  28. package/src/components/Spreadsheet/composables/useSpreadsheetClipboard.ts +71 -0
  29. package/src/components/Spreadsheet/composables/useSpreadsheetColumns.ts +110 -0
  30. package/src/components/Spreadsheet/composables/useSpreadsheetSelection.ts +43 -0
  31. package/src/components/Spreadsheet/composables/useSpreadsheetSort.ts +42 -0
  32. package/src/components/Spreadsheet/composables/useSpreadsheetUndo.ts +55 -0
  33. package/src/components/Spreadsheet/types.ts +35 -0
  34. package/src/components/Spreadsheet/utils.ts +69 -0
@@ -1,243 +1,85 @@
1
1
  <script lang="ts" setup>
2
+ import type { SpreadsheetProps, CellPosition } from './types'
2
3
  import { Btn, CheckInput, TextInput, ToggleInput, useLocalStore, ListItem, Dropdown, useI18n } from '@bagelink/vue'
3
- import { computed, ref, watch, nextTick, onUnmounted } from 'vue'
4
+ import { computed, ref, watch, nextTick } from 'vue'
5
+ import { useSpreadsheetClipboard } from './composables/useSpreadsheetClipboard'
6
+ import { useSpreadsheetColumns } from './composables/useSpreadsheetColumns'
7
+ import { useSpreadsheetSelection } from './composables/useSpreadsheetSelection'
8
+ import { useSpreadsheetSort } from './composables/useSpreadsheetSort'
9
+ import { useSpreadsheetUndo } from './composables/useSpreadsheetUndo'
4
10
  import SpreadsheetTable from './SpreadsheetTable.vue'
11
+ import { flattenObject, unflattenObject, parseValueForFormat } from './utils'
5
12
 
6
- const { modelValue, columnConfig, label, allowAddRow = true } = defineProps<Props>()
13
+ const { modelValue, columnConfig, label, allowAddRow = true } = defineProps<SpreadsheetProps>()
7
14
 
8
15
  const emit = defineEmits(['update:modelValue'])
9
-
10
16
  const { $t } = useI18n()
17
+ const { wrapText } = useLocalStore()
11
18
 
12
- // Define column configuration types
13
- type ColumnFormat = 'text' | 'number' | 'currency' | 'date' | 'percentage' | 'image' | 'boolean'
14
-
15
- interface ColumnConfig {
16
- key: string
17
- label?: string
18
- locked?: boolean
19
- format?: ColumnFormat
20
- sortable?: boolean
21
- width?: string
22
- fixed?: boolean
23
- hidden?: boolean
24
- defaultValue?: any
25
- }
26
-
27
- // Define props interface with column configuration
28
- interface Props {
29
- modelValue: Array<{ [key: string]: any }>
30
- columnConfig?: ColumnConfig[]
31
- label?: string
32
- allowAddRow?: boolean
33
- }
34
-
35
- // Helper function to flatten an object with dot notation
36
- function flattenObject(obj: { [key: string]: any }, prefix = ''): { [key: string]: any } {
37
- const flattened: { [key: string]: any } = {}
38
-
39
- for (const key in obj) {
40
- if (Object.hasOwn(obj, key)) {
41
- const value = obj[key]
42
- const newKey = prefix ? `${prefix}.${key}` : key
43
-
44
- if (value && typeof value === 'object' && !Array.isArray(value)) {
45
- Object.assign(flattened, flattenObject(value, newKey))
46
- } else {
47
- flattened[newKey] = value
48
- }
49
- }
50
- }
51
-
52
- return flattened
53
- }
54
-
55
- // Helper function to unflatten an object from dot notation
56
- function unflattenObject(obj: { [key: string]: any }): { [key: string]: any } {
57
- const result: { [key: string]: any } = {}
58
-
59
- for (const key in obj) {
60
- if (Object.hasOwn(obj, key)) {
61
- const keys = key.split('.')
62
- let current = result
63
-
64
- for (let i = 0; i < keys.length - 1; i++) {
65
- const k = keys[i]
66
- if (!current[k]) {
67
- current[k] = {}
68
- }
69
- current = current[k]
70
- }
71
-
72
- current[keys[keys.length - 1]] = obj[key]
73
- }
74
- }
75
-
76
- return result
77
- }
78
-
79
- // Create local copy of the modelValue with flattened objects
80
- const localRows = ref<Array<{ [key: string]: any }>>(
81
- modelValue
82
- ? modelValue.map(row => flattenObject({ ...row }))
83
- : []
19
+ const localRows = ref(
20
+ modelValue ? modelValue.map(row => flattenObject({ ...row })) : []
84
21
  )
85
22
 
86
- // Update the watch handler to handle flattening
87
23
  watch(() => modelValue, (newVal) => {
88
- localRows.value = newVal
89
- ? newVal.map(row => flattenObject({ ...row }))
90
- : []
24
+ localRows.value = newVal ? newVal.map(row => flattenObject({ ...row })) : []
91
25
  })
92
26
 
93
- // Update the emit to unflatten the data
94
27
  function emitUpdate() {
95
28
  emit('update:modelValue', localRows.value.map(row => unflattenObject({ ...row })))
96
29
  }
97
30
 
98
- // After other ref declarations but before the columns computed property
99
- const visibleColumns = ref<string[]>([])
100
-
101
- // Sort state
102
- const sortColumn = ref<string | null>(null)
103
- const sortDirection = ref<'asc' | 'desc'>('asc')
104
-
105
- // Update the columns computed property
106
- const columns = computed(() => {
107
- // Get all unique keys from the data
108
- const dataKeys = localRows.value.length
109
- ? new Set(localRows.value.flatMap(row => Object.keys(row)))
110
- : new Set<string>()
111
-
112
- // Create a map of configured columns
113
- const configMap = new Map(
114
- (columnConfig || []).map(config => [config.key, config])
115
- )
116
-
117
- // Merge configured columns with data keys
118
- const allKeys = new Set([...dataKeys, ...(columnConfig?.map(c => c.key) || [])])
119
-
120
- return Array.from(allKeys).map((key) => {
121
- const configuredColumn = configMap.get(key)
122
- return {
123
- key,
124
- label: configuredColumn?.label ?? key,
125
- locked: configuredColumn?.locked ?? false,
126
- sortable: configuredColumn?.sortable ?? true,
127
- format: configuredColumn?.format ?? 'text',
128
- width: configuredColumn?.width,
129
- fixed: configuredColumn?.fixed ?? false,
130
- hidden: configuredColumn?.hidden ?? false,
131
- defaultValue: configuredColumn?.defaultValue
132
- } as ColumnConfig
133
- })
134
- })
135
-
136
- // Format cell value based on column configuration
137
- function formatCellValue(value: any, format?: ColumnFormat): string {
138
- if (value === null || value === undefined) { return '' }
139
-
140
- switch (format) {
141
- case 'image':
142
- return String(value) // Return raw value for images
143
- case 'boolean':
144
- return '' // Return empty string since we're using CheckInput component
145
- case 'number':
146
- return Number(value).toLocaleString()
147
- case 'currency':
148
- return Number(value).toLocaleString(undefined, {
149
- style: 'currency',
150
- currency: 'USD'
151
- })
152
- case 'date':
153
- return value ? new Date(value).toLocaleDateString() : ''
154
- case 'percentage':
155
- return Number(value).toLocaleString(undefined, {
156
- style: 'percent',
157
- minimumFractionDigits: 2
158
- })
159
- default:
160
- return String(value)
161
- }
162
- }
163
-
164
- // Update the parseValueForFormat function to handle type conversions properly
165
- function parseValueForFormat(value: string | boolean, format?: ColumnFormat): any {
166
- switch (format) {
167
- case 'boolean':
168
- return typeof value === 'boolean' ? value : value === 'true'
169
- case 'number':
170
- case 'percentage':
171
- case 'currency':
172
- if (typeof value === 'boolean') { return null }
173
- return value === '' ? null : Number(String(value).replace(/[^0-9.-]/g, ''))
174
- case 'date':
175
- if (typeof value === 'boolean') { return null }
176
- return value === '' ? null : new Date(String(value)).toISOString()
177
- default:
178
- return String(value)
179
- }
180
- }
181
-
182
- // Check if a cell is editable
183
- function isCellEditable(columnKey: string): boolean {
184
- const column = columns.value.find(col => col.key === columnKey)
185
- return !(column?.locked ?? false)
186
- }
187
-
188
- // Update the sortByColumn function to preserve column visibility
189
- function sortByColumn(columnKey: string) {
190
- const column = columns.value.find(col => col.key === columnKey)
191
- if (!column?.sortable) { return }
192
-
193
- if (sortColumn.value === columnKey) {
194
- sortDirection.value = sortDirection.value === 'desc' ? 'asc' : 'desc'
195
- } else {
196
- sortColumn.value = columnKey
197
- sortDirection.value = 'desc'
198
- }
199
-
200
- const sorted = [...localRows.value].sort((a, b) => {
201
- const aVal = a[columnKey]
202
- const bVal = b[columnKey]
203
-
204
- if (aVal === bVal) { return 0 }
205
- if (aVal === null || aVal === undefined) { return 1 }
206
- if (bVal === null || bVal === undefined) { return -1 }
207
-
208
- const modifier = sortDirection.value === 'desc' ? 1 : -1
209
- return aVal < bVal ? -modifier : modifier
210
- })
211
-
212
- // Save the current visibleColumns state
213
- const currentVisibleColumns = [...visibleColumns.value]
31
+ const {
32
+ columns,
33
+ fixedColumns,
34
+ scrollableColumns,
35
+ columnOptions,
36
+ visibleColumns,
37
+ columnWidths,
38
+ isCellEditable,
39
+ createEmptyRow,
40
+ handleResizeStart,
41
+ } = useSpreadsheetColumns(localRows, () => columnConfig)
42
+
43
+ const { sortColumn, sortDirection, sortByColumn } = useSpreadsheetSort(
44
+ localRows,
45
+ columns,
46
+ visibleColumns,
47
+ emitUpdate
48
+ )
214
49
 
215
- localRows.value = sorted
216
- emitUpdate()
50
+ const {
51
+ selectionStart,
52
+ selectionEnd,
53
+ handleMouseDown,
54
+ handleMouseOver,
55
+ handleMouseUp,
56
+ selectEntireRow,
57
+ } = useSpreadsheetSelection(columns)
58
+
59
+ const { canUndo, canRedo, saveState, undo, redo } = useSpreadsheetUndo(
60
+ localRows,
61
+ selectionStart,
62
+ selectionEnd,
63
+ emitUpdate
64
+ )
217
65
 
218
- // Restore the visibleColumns state after the update
219
- nextTick(() => {
220
- visibleColumns.value = currentVisibleColumns
221
- })
222
- }
66
+ const { copySelection, pasteSelection } = useSpreadsheetClipboard(
67
+ localRows,
68
+ columns,
69
+ selectionStart,
70
+ selectionEnd,
71
+ saveState,
72
+ isCellEditable,
73
+ createEmptyRow,
74
+ emitUpdate
75
+ )
223
76
 
224
- // Variables to handle cell selection
225
- interface CellPosition {
226
- row: number
227
- col: number
228
- }
229
- const isSelecting = ref(false)
230
- const selectionStart = ref<CellPosition | null>(null)
231
- const selectionEnd = ref<CellPosition | null>(null)
232
- const { wrapText } = useLocalStore()
77
+ // ─── Editing ─────────────────────────────────────────────────────────────────
233
78
 
234
- // Reactive variable to track the currently editing cell
235
79
  const editingCell = ref<CellPosition | null>(null)
236
-
237
- // Update the editInputRef to use a Map
80
+ const editingOriginalValue = ref<string | null>(null)
238
81
  const editInputRef = ref(new Map<string, HTMLInputElement>())
239
82
 
240
- // Update the ref handling functions
241
83
  function setInputRef(el: any, key: string) {
242
84
  if (el?.$el instanceof HTMLInputElement) {
243
85
  editInputRef.value.set(key, el.$el)
@@ -248,265 +90,9 @@ function setInputRef(el: any, key: string) {
248
90
  }
249
91
  }
250
92
 
251
- // // Determines if the given cell is within the currently selected range
252
- // function isCellSelected(row: number, col: number): boolean {
253
- // if (!selectionStart.value || !selectionEnd.value) return false
254
- // const startRow = Math.min(selectionStart.value.row, selectionEnd.value.row)
255
- // const endRow = Math.max(selectionStart.value.row, selectionEnd.value.row)
256
- // const startCol = Math.min(selectionStart.value.col, selectionEnd.value.col)
257
- // const endCol = Math.max(selectionStart.value.col, selectionEnd.value.col)
258
- // return row >= startRow && row <= endRow && col >= startCol && col <= endCol
259
- // }
260
-
261
- // Mouse event handlers to manage the selection range
262
- function handleMouseDown(row: number, col: number) {
263
- selectionStart.value = { row, col }
264
- selectionEnd.value = { row, col }
265
- isSelecting.value = true
266
- }
267
-
268
- function handleMouseOver(row: number, col: number) {
269
- if (isSelecting.value && selectionStart.value) {
270
- selectionEnd.value = { row, col }
271
- }
272
- }
273
-
274
- function handleMouseUp() {
275
- isSelecting.value = false
276
- }
277
-
278
- // Add types and state for undo/redo functionality
279
- interface SpreadsheetChange {
280
- type: 'cell' | 'row' | 'paste'
281
- data: {
282
- rows: Array<{ [key: string]: any }>
283
- selection?: { start: CellPosition, end: CellPosition }
284
- }
285
- }
286
-
287
- const undoStack = ref<SpreadsheetChange[]>([])
288
- const redoStack = ref<SpreadsheetChange[]>([])
289
-
290
- // Function to save state before making changes
291
- function saveState(type: SpreadsheetChange['type']) {
292
- undoStack.value.push({
293
- type,
294
- data: {
295
- rows: JSON.parse(JSON.stringify(localRows.value)),
296
- selection: selectionStart.value && selectionEnd.value
297
- ? {
298
- start: { ...selectionStart.value },
299
- end: { ...selectionEnd.value }
300
- }
301
- : undefined
302
- }
303
- })
304
- // Clear redo stack when new changes are made
305
- redoStack.value = []
306
- }
307
-
308
- // Undo/Redo functions
309
- function undo() {
310
- const change = undoStack.value.pop()
311
- if (change) {
312
- // Save current state to redo stack
313
- redoStack.value.push({
314
- type: change.type,
315
- data: {
316
- rows: JSON.parse(JSON.stringify(localRows.value)),
317
- selection: selectionStart.value && selectionEnd.value
318
- ? {
319
- start: { ...selectionStart.value },
320
- end: { ...selectionEnd.value }
321
- }
322
- : undefined
323
- }
324
- })
325
-
326
- // Restore previous state
327
- localRows.value = JSON.parse(JSON.stringify(change.data.rows))
328
- if (change.data.selection) {
329
- selectionStart.value = { ...change.data.selection.start }
330
- selectionEnd.value = { ...change.data.selection.end }
331
- }
332
- emitUpdate()
333
- }
334
- }
335
-
336
- function redo() {
337
- const change = redoStack.value.pop()
338
- if (change) {
339
- // Save current state to undo stack
340
- undoStack.value.push({
341
- type: change.type,
342
- data: {
343
- rows: JSON.parse(JSON.stringify(localRows.value)),
344
- selection: selectionStart.value && selectionEnd.value
345
- ? {
346
- start: { ...selectionStart.value },
347
- end: { ...selectionEnd.value }
348
- }
349
- : undefined
350
- }
351
- })
352
-
353
- // Restore next state
354
- localRows.value = JSON.parse(JSON.stringify(change.data.rows))
355
- if (change.data.selection) {
356
- selectionStart.value = { ...change.data.selection.start }
357
- selectionEnd.value = { ...change.data.selection.end }
358
- }
359
- emitUpdate()
360
- }
361
- }
362
-
363
- // Update updateCell to use undo stack
364
- function updateCell(rowIndex: number, key: string, newValue: string | boolean) {
365
- const column = columns.value.find(col => col.key === key)
366
- if (column?.locked) { return }
367
-
368
- saveState('cell')
369
- const parsedValue = parseValueForFormat(newValue, column?.format)
370
- // If the parsed value is null/undefined, use the default value
371
- localRows.value[rowIndex][key] = parsedValue ?? column?.defaultValue ?? (column?.format === 'boolean' ? false : '')
372
- emitUpdate()
373
- }
374
-
375
- // After other ref declarations but before computed properties
376
- // const visibleColumns = ref<string[]>([])
377
-
378
- // After the columns computed property
379
- // Initialize visibleColumns with all columns when columns change
380
- watch(() => columns.value, (newColumns) => {
381
- if (newColumns.length > 0 && visibleColumns.value.length === 0) {
382
- visibleColumns.value = newColumns.filter(col => !col.hidden).map(col => col.key)
383
- }
384
- })
385
-
386
- // Update the fixedColumns computed property
387
- const fixedColumns = computed(() => {
388
- return columns.value.filter(col => col.fixed && !col.hidden && visibleColumns.value.includes(col.key))
389
- })
390
-
391
- // Update the scrollableColumns computed property
392
- const scrollableColumns = computed(() => {
393
- return columns.value.filter(col => !col.fixed && !col.hidden && visibleColumns.value.includes(col.key))
394
- })
395
-
396
- // Update createEmptyRow to use defaultValue from column config
397
- function createEmptyRow(): { [key: string]: any } {
398
- const newRow: { [key: string]: any } = {}
399
- columns.value.forEach((col) => {
400
- // Use defaultValue if provided, otherwise use format-specific defaults
401
- if (col.defaultValue !== undefined) {
402
- newRow[col.key] = col.defaultValue
403
- } else {
404
- newRow[col.key] = col.format === 'boolean' ? false : ''
405
- }
406
- })
407
- return newRow
408
- }
409
-
410
- // Update addRow to use the createEmptyRow function
411
- function addRow() {
412
- if (columns.value.length) {
413
- saveState('row')
414
- localRows.value.push(createEmptyRow())
415
- emitUpdate()
416
- }
417
- }
418
-
419
- // Copy function using Navigator Clipboard API
420
- async function copySelection() {
421
- if (!selectionStart.value || !selectionEnd.value) { return }
422
-
423
- const startRow = Math.min(selectionStart.value.row, selectionEnd.value.row)
424
- const endRow = Math.max(selectionStart.value.row, selectionEnd.value.row)
425
- const startCol = Math.min(selectionStart.value.col, selectionEnd.value.col)
426
- const endCol = Math.max(selectionStart.value.col, selectionEnd.value.col)
427
-
428
- const selectedData = []
429
- for (let i = startRow; i <= endRow; i++) {
430
- const rowData = []
431
- for (let j = startCol; j <= endCol; j++) {
432
- const columnKey = columns.value[j].key
433
- const value = localRows.value[i][columnKey]
434
- rowData.push(formatCellValue(value, columns.value[j].format))
435
- }
436
- selectedData.push(rowData)
437
- }
438
-
439
- // Convert to TSV format for clipboard
440
- const tsvContent = selectedData.map(row => row.join('\t')).join('\n')
441
-
442
- try {
443
- await navigator.clipboard.writeText(tsvContent)
444
- } catch (err) {
445
- console.error('Failed to copy to clipboard:', err)
446
- }
447
- }
448
-
449
- // Add a ref for the search input
450
- const searchInputRef = ref<any>(null)
451
-
452
- // Add a method to check if the search input is focused
453
- function isSearchFocused(): boolean {
454
- const inputElement = searchInputRef.value?.$el?.querySelector('input')
455
- return document.activeElement === inputElement
456
- }
457
-
458
- // Update the pasteSelection function to handle search focus
459
- async function pasteSelection() {
460
- if (!selectionStart.value) { return }
461
-
462
- try {
463
- const clipboardText = await navigator.clipboard.readText()
464
- const rows = clipboardText.split('\n').map(row => row.split('\t'))
465
-
466
- saveState('paste')
467
-
468
- const startRow = selectionStart.value.row
469
- const startCol = selectionStart.value.col
470
-
471
- // Calculate how many new rows we need to add
472
- const neededRows = startRow + rows.length - localRows.value.length
473
- if (neededRows > 0) {
474
- // Add the required number of new rows
475
- for (let i = 0; i < neededRows; i++) {
476
- localRows.value.push(createEmptyRow())
477
- }
478
- }
479
-
480
- // Update the data
481
- rows.forEach((rowData, rowIndex) => {
482
- const targetRow = startRow + rowIndex
483
- rowData.forEach((cellValue, colIndex) => {
484
- const targetCol = startCol + colIndex
485
- if (targetCol >= columns.value.length) { return }
486
-
487
- const columnKey = columns.value[targetCol].key
488
- if (!isCellEditable(columnKey)) { return }
489
-
490
- const { format } = columns.value[targetCol]
491
- localRows.value[targetRow][columnKey] = parseValueForFormat(cellValue, format)
492
- })
493
- })
494
-
495
- emitUpdate()
496
- } catch (err) {
497
- console.error('Failed to paste from clipboard:', err)
498
- }
499
- }
500
-
501
- // Add a variable to track the original value before editing
502
- const editingOriginalValue = ref<string | null>(null)
503
-
504
- // Update the startEditing function to handle focus properly
505
93
  function startEditing(row: number, col: number, initialKey?: string) {
506
94
  const columnKey = columns.value[col]?.key
507
95
  if (!columnKey) { return }
508
-
509
- // Only check editability when we're actually going to edit
510
96
  if (initialKey !== undefined && !isCellEditable(columnKey)) { return }
511
97
 
512
98
  editingCell.value = { row, col }
@@ -516,183 +102,99 @@ function startEditing(row: number, col: number, initialKey?: string) {
516
102
  updateCell(row, columnKey, initialKey)
517
103
  }
518
104
 
519
- // Focus the input on the next tick after Vue has updated the DOM
520
105
  nextTick(() => {
521
- const inputKey = `cell-${row}-${col}`
522
- const input = editInputRef.value.get(inputKey)
106
+ const input = editInputRef.value.get(`cell-${row}-${col}`)
523
107
  if (input) {
524
108
  input.focus()
525
- // If we have an initial key, set the selection to the end
526
- if (initialKey !== undefined) {
527
- input.value = initialKey
528
- }
109
+ if (initialKey !== undefined) { input.value = initialKey }
529
110
  }
530
111
  })
531
112
  }
532
113
 
533
- // Update the stopEditing function to handle cancellation
534
114
  function stopEditing(cancelled = false) {
535
115
  if (cancelled && editingCell.value && editingOriginalValue.value !== null) {
536
116
  const { row, col } = editingCell.value
537
117
  const columnKey = columns.value[col]?.key
538
- if (columnKey) {
539
- localRows.value[row][columnKey] = editingOriginalValue.value
540
- }
118
+ if (columnKey) { localRows.value[row][columnKey] = editingOriginalValue.value }
541
119
  }
542
120
  editingCell.value = null
543
121
  editingOriginalValue.value = null
544
122
  }
545
123
 
546
- // Update the handleStopEditingAndBlur function
547
124
  function handleStopEditingAndBlur(cancelled = false) {
548
125
  nextTick(() => {
549
126
  stopEditing(cancelled)
550
127
  nextTick(() => {
551
- // Force focus back to the main spreadsheet container
552
- const spreadsheet = document.querySelector('.spreadsheet') as HTMLElement
553
- if (spreadsheet) {
554
- spreadsheet.focus()
555
- }
128
+ (document.querySelector('.spreadsheet') as HTMLElement)?.focus()
556
129
  })
557
130
  })
558
131
  }
559
132
 
560
- window.addEventListener('mouseup', handleMouseUp)
561
-
562
- // First add a function to handle row selection
563
- function selectEntireRow(rowIndex: number) {
564
- selectionStart.value = { row: rowIndex, col: 0 }
565
- selectionEnd.value = { row: rowIndex, col: columns.value.length - 1 }
133
+ function updateCell(rowIndex: number, key: string, newValue: string | boolean) {
134
+ const column = columns.value.find(col => col.key === key)
135
+ if (column?.locked) { return }
136
+ saveState('cell')
137
+ const parsed = parseValueForFormat(newValue, column?.format)
138
+ localRows.value[rowIndex][key] = parsed ?? column?.defaultValue ?? (column?.format === 'boolean' ? false : '')
139
+ emitUpdate()
566
140
  }
567
141
 
568
- // Add back the handleCellKeyDown function
569
- function handleCellKeyDown(event: KeyboardEvent, row: number, col: number) {
570
- // If this cell is not already in edit mode
571
- if (!(editingCell.value && editingCell.value.row === row && editingCell.value.col === col)) {
572
- // Start editing if a printable character or Enter is pressed
573
- if ((event.key.length === 1 && !event.ctrlKey && !event.metaKey) || event.key === 'Enter') {
574
- event.preventDefault()
575
- startEditing(row, col, event.key.length === 1 ? event.key : undefined)
576
- }
577
- }
578
- }
142
+ // ─── Search ───────────────────────────────────────────────────────────────────
579
143
 
580
- // Update keyboard shortcuts to include undo/redo
581
- function handleSpreadsheetKeyDown(event: KeyboardEvent) {
582
- // Don't intercept keyboard shortcuts when editing a cell
583
- if (editingCell.value || isSearchFocused()) { return }
584
- console.log('handleSpreadsheetKeyDown', event)
585
- const isCtrlOrCmd = event.ctrlKey || event.metaKey
144
+ const search = ref('')
145
+ const searchInputRef = ref<any>(null)
586
146
 
587
- if (isCtrlOrCmd) {
588
- switch (event.key.toLowerCase()) {
589
- case 'c':
590
- event.preventDefault()
591
- copySelection()
592
- break
593
- case 'v':
594
- event.preventDefault()
595
- pasteSelection()
596
- break
597
- case 'z':
598
- event.preventDefault()
599
- if (event.shiftKey) {
600
- redo()
601
- } else {
602
- undo()
603
- }
604
- break
605
- case 'y':
606
- event.preventDefault()
607
- redo()
608
- break
609
- }
610
- }
147
+ function isSearchFocused(): boolean {
148
+ const input = searchInputRef.value?.$el?.querySelector('input')
149
+ return document.activeElement === input
611
150
  }
612
151
 
613
- // Add computed properties for undo/redo stack state
614
- const canUndo = computed(() => undoStack.value.length > 0)
615
- const canRedo = computed(() => redoStack.value.length > 0)
616
-
617
- // Add after other ref declarations
618
- const search = ref('')
619
-
620
- // Add the filteredRows computed property after the columns computed
621
152
  const filteredRows = computed(() => {
622
153
  if (!search.value) { return localRows.value }
623
-
624
- const searchTerm = search.value.toLowerCase()
625
- return localRows.value.filter((row) => {
626
- // Check all values in the row, including hidden columns
627
- return Object.values(row).some((value) => {
628
- if (value === null || value === undefined) { return false }
629
- return String(value).toLowerCase().includes(searchTerm)
630
- })
631
- })
154
+ const term = search.value.toLowerCase()
155
+ return localRows.value.filter(row => Object.values(row).some(v => v != null && String(v).toLowerCase().includes(term))
156
+ )
632
157
  })
633
158
 
634
- // Add after other ref declarations
635
- const columnWidths = ref<Map<string, number>>(new Map())
636
- const isResizing = ref(false)
637
- const resizingColumn = ref<string | null>(null)
638
- const startX = ref<number>(0)
639
- const startWidth = ref<number>(0)
640
-
641
- // Add after other function declarations
642
- function handleResizeStart(e: MouseEvent, columnKey: string) {
643
- isResizing.value = true
644
- resizingColumn.value = columnKey
645
- startX.value = e.pageX
646
-
647
- // Find the column header element
648
- const columnHeader = (e.target as HTMLElement).closest('th')
649
- if (!columnHeader) { return }
650
-
651
- // Get the actual computed width of the column
652
- const computedWidth = columnHeader.getBoundingClientRect().width
653
- const currentWidth = columnWidths.value.get(columnKey) || computedWidth
654
- startWidth.value = currentWidth
655
-
656
- // Add event listeners for mousemove and mouseup
657
- document.addEventListener('mousemove', handleResizeMove)
658
- document.addEventListener('mouseup', handleResizeEnd)
659
- }
660
-
661
- function handleResizeMove(e: MouseEvent) {
662
- if (!isResizing.value || !resizingColumn.value) { return }
663
-
664
- e.preventDefault()
159
+ // ─── Row management ───────────────────────────────────────────────────────────
665
160
 
666
- const diff = e.pageX - startX.value
667
- const newWidth = Math.max(80, startWidth.value + diff) // Keep minimum width of 80px
668
-
669
- columnWidths.value.set(resizingColumn.value, newWidth)
161
+ function addRow() {
162
+ if (!columns.value.length) { return }
163
+ saveState('row')
164
+ localRows.value.push(createEmptyRow())
165
+ emitUpdate()
670
166
  }
671
167
 
672
- function handleResizeEnd() {
673
- isResizing.value = false
674
- resizingColumn.value = null
168
+ // ─── Keyboard ────────────────────────────────────────────────────────────────
675
169
 
676
- // Remove event listeners
677
- document.removeEventListener('mousemove', handleResizeMove)
678
- document.removeEventListener('mouseup', handleResizeEnd)
170
+ function handleCellKeyDown(event: KeyboardEvent, row: number, col: number) {
171
+ const isThisCell = editingCell.value?.row === row && editingCell.value?.col === col
172
+ if (!isThisCell && ((event.key.length === 1 && !event.ctrlKey && !event.metaKey) || event.key === 'Enter')) {
173
+ event.preventDefault()
174
+ startEditing(row, col, event.key.length === 1 ? event.key : undefined)
175
+ }
679
176
  }
680
177
 
681
- // Clean up event listeners when component is unmounted
682
- onUnmounted(() => {
683
- document.removeEventListener('mousemove', handleResizeMove)
684
- document.removeEventListener('mouseup', handleResizeEnd)
685
- })
178
+ function handleSpreadsheetKeyDown(event: KeyboardEvent) {
179
+ if (editingCell.value || isSearchFocused()) { return }
180
+ const isCtrlOrCmd = event.ctrlKey || event.metaKey
181
+ if (!isCtrlOrCmd) { return }
686
182
 
687
- // Add a computed property for visible column options
688
- const columnOptions = computed(() => {
689
- return columns.value.filter(col => !col.hidden)
690
- })
183
+ switch (event.key.toLowerCase()) {
184
+ case 'c': event.preventDefault(); copySelection(); break
185
+ case 'v': event.preventDefault(); pasteSelection(); break
186
+ case 'z':
187
+ event.preventDefault()
188
+ event.shiftKey ? redo() : undo()
189
+ break
190
+ case 'y': event.preventDefault(); redo(); break
191
+ }
192
+ }
691
193
  </script>
692
194
 
693
195
  <template>
694
196
  <div class="w-100p" tabindex="-1" @keydown="handleSpreadsheetKeyDown">
695
- <div class="flex gap-05 py-05 justify-content-end m_flex-wrap">
197
+ <div class="flex gap-05 py-05 justify-content-end m_flex-wrap spreadsheet-toolbar">
696
198
  <label v-if="label" class="label me-auto">{{ label }}</label>
697
199
  <div class="flex gap-075">
698
200
  <TextInput
@@ -701,7 +203,7 @@ const columnOptions = computed(() => {
701
203
  />
702
204
  <Dropdown flat thin icon="more_vert">
703
205
  <ListItem title="Paste" icon="paste" @click="pasteSelection" />
704
- <ListItem title="copy" icon="copy" @click="copySelection" />
206
+ <ListItem title="Copy" icon="copy" @click="copySelection" />
705
207
  <ListItem title="Undo" icon="undo" :disabled="!canUndo" @click="undo" />
706
208
  <ListItem title="Redo" icon="redo" :disabled="!canRedo" @click="redo" />
707
209
  <ToggleInput v-model="wrapText" :label="$t('spreadsheet.wrapText')" />
@@ -719,7 +221,7 @@ const columnOptions = computed(() => {
719
221
  </div>
720
222
  <CheckInput
721
223
  v-for="col in columnOptions" :key="col.key" v-model="visibleColumns"
722
- :value="col.key" :label="col.label || col.key" @update:modelValue="val => undefined"
224
+ :value="col.key" :label="col.label || col.key"
723
225
  />
724
226
  </div>
725
227
  </Dropdown>
@@ -728,28 +230,29 @@ const columnOptions = computed(() => {
728
230
  </div>
729
231
  <div class="spreadsheet" @mouseup="handleMouseUp">
730
232
  <div class="flex w-100p relative">
731
- <!-- Fixed Columns -->
732
233
  <SpreadsheetTable
733
234
  v-if="fixedColumns.length" :columns="fixedColumns" :rows="filteredRows"
734
- :is-fixed="true" :show-row-numbers="true" :column-widths="columnWidths" :sort-column="sortColumn"
735
- :sort-direction="sortDirection" :selection-start="selectionStart" :selection-end="selectionEnd"
235
+ :is-fixed="true" :show-row-numbers="true" :column-widths="columnWidths"
236
+ :sort-column="sortColumn" :sort-direction="sortDirection"
237
+ :selection-start="selectionStart" :selection-end="selectionEnd"
736
238
  :editing-cell="editingCell" :base-column-index="0" :wrap-text="wrapText"
737
- class="sticky z-2 start-0 bg-white" @sortColumn="sortByColumn" @resizeStart="handleResizeStart"
239
+ class="sticky z-2 start-0 bg-white"
240
+ @sortColumn="sortByColumn" @resizeStart="handleResizeStart"
738
241
  @selectRow="selectEntireRow" @cellMouseDown="handleMouseDown" @cellMouseOver="handleMouseOver"
739
242
  @cellEditStart="startEditing" @cellKeyDown="handleCellKeyDown" @updateCell="updateCell"
740
243
  @stopEditing="handleStopEditingAndBlur" @setInputRef="setInputRef"
741
244
  />
742
-
743
- <!-- Scrollable Columns -->
744
245
  <div class="flex-shrink flex-grow">
745
246
  <SpreadsheetTable
746
- :columns="scrollableColumns" :rows="filteredRows" :is-fixed="false"
747
- :show-row-numbers="!fixedColumns.length" :column-widths="columnWidths" :sort-column="sortColumn"
748
- :sort-direction="sortDirection" :selection-start="selectionStart" :selection-end="selectionEnd"
247
+ :columns="scrollableColumns" :rows="filteredRows"
248
+ :is-fixed="false" :show-row-numbers="!fixedColumns.length" :column-widths="columnWidths"
249
+ :empty-message="search ? `No results for '${search}'` : undefined"
250
+ :sort-column="sortColumn" :sort-direction="sortDirection"
251
+ :selection-start="selectionStart" :selection-end="selectionEnd"
749
252
  :editing-cell="editingCell" :base-column-index="fixedColumns.length" :wrap-text="wrapText"
750
- @sortColumn="sortByColumn" @resizeStart="handleResizeStart" @selectRow="selectEntireRow"
751
- @cellMouseDown="handleMouseDown" @cellMouseOver="handleMouseOver" @cellEditStart="startEditing"
752
- @cellKeyDown="handleCellKeyDown" @updateCell="updateCell"
253
+ @sortColumn="sortByColumn" @resizeStart="handleResizeStart"
254
+ @selectRow="selectEntireRow" @cellMouseDown="handleMouseDown" @cellMouseOver="handleMouseOver"
255
+ @cellEditStart="startEditing" @cellKeyDown="handleCellKeyDown" @updateCell="updateCell"
753
256
  @stopEditing="handleStopEditingAndBlur" @setInputRef="setInputRef"
754
257
  />
755
258
  </div>
@@ -760,12 +263,17 @@ const columnOptions = computed(() => {
760
263
  </template>
761
264
 
762
265
  <style>
763
- /* Spreadsheet container styles */
764
266
  .spreadsheet {
765
267
  user-select: none;
766
268
  outline: 1px solid var(--border-color);
767
269
  }
768
270
 
271
+ .spreadsheet-toolbar {
272
+ border-bottom: 1px solid var(--border-color);
273
+ padding-bottom: 0.375rem;
274
+ margin-bottom: 0;
275
+ }
276
+
769
277
  body>div:has(.spreadsheet) ::-webkit-scrollbar-track {
770
278
  background: var(--input-bg) !important;
771
279
  }