@stonecrop/atable 0.31.0 → 0.33.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 (79) hide show
  1. package/dist/assets/index.css +804 -1
  2. package/dist/atable.js +2809 -1944
  3. package/dist/atable.js.map +1 -1
  4. package/dist/src/components/ACell.vue.d.ts +17 -0
  5. package/dist/src/components/ACell.vue.d.ts.map +1 -0
  6. package/dist/src/components/AGanttCell.vue.d.ts +34 -0
  7. package/dist/src/components/AGanttCell.vue.d.ts.map +1 -0
  8. package/dist/src/components/AGanttConnection.vue.d.ts +13 -0
  9. package/dist/src/components/AGanttConnection.vue.d.ts.map +1 -0
  10. package/dist/src/components/ARow.vue.d.ts +2693 -0
  11. package/dist/src/components/ARow.vue.d.ts.map +1 -0
  12. package/dist/src/components/ARowActions.vue.d.ts +16 -0
  13. package/dist/src/components/ARowActions.vue.d.ts.map +1 -0
  14. package/dist/src/components/ATable.vue.d.ts +13358 -0
  15. package/dist/src/components/ATable.vue.d.ts.map +1 -0
  16. package/dist/src/components/ATableColumnFilter.vue.d.ts +11 -0
  17. package/dist/src/components/ATableColumnFilter.vue.d.ts.map +1 -0
  18. package/dist/src/components/ATableHeader.vue.d.ts +20 -0
  19. package/dist/src/components/ATableHeader.vue.d.ts.map +1 -0
  20. package/dist/src/components/ATableLoading.vue.d.ts +14 -0
  21. package/dist/src/components/ATableLoading.vue.d.ts.map +1 -0
  22. package/dist/src/components/ATableLoadingBar.vue.d.ts +14 -0
  23. package/dist/src/components/ATableLoadingBar.vue.d.ts.map +1 -0
  24. package/dist/src/components/ATableModal.vue.d.ts +18 -0
  25. package/dist/src/components/ATableModal.vue.d.ts.map +1 -0
  26. package/dist/src/components/ATablePaginationFooter.vue.d.ts +17 -0
  27. package/dist/src/components/ATablePaginationFooter.vue.d.ts.map +1 -0
  28. package/dist/src/icons/index.d.ts +14 -7
  29. package/dist/src/icons/index.d.ts.map +1 -1
  30. package/dist/{tsdoc-metadata.json → src/tsdoc-metadata.json} +1 -1
  31. package/dist/src/types/index.d.ts.map +1 -1
  32. package/package.json +35 -29
  33. package/dist/atable.d.ts +0 -3560
  34. package/dist/atable.tsbuildinfo +0 -1
  35. package/dist/icons/stonecrop-ui-icon-add.svg +0 -5
  36. package/dist/icons/stonecrop-ui-icon-delete.svg +0 -5
  37. package/dist/icons/stonecrop-ui-icon-duplicate.svg +0 -5
  38. package/dist/icons/stonecrop-ui-icon-insert-above.svg +0 -15
  39. package/dist/icons/stonecrop-ui-icon-insert-below.svg +0 -15
  40. package/dist/icons/stonecrop-ui-icon-move.svg +0 -4
  41. package/dist/icons/stonecrop-ui-icon-open.svg +0 -5
  42. package/dist/src/composables/table-pagination.js +0 -108
  43. package/dist/src/icons/index.js +0 -32
  44. package/dist/src/index.js +0 -33
  45. package/dist/src/linkSearchableText.js +0 -44
  46. package/dist/src/resolveFilterType.js +0 -27
  47. package/dist/src/schemaToColumns.js +0 -59
  48. package/dist/src/stores/table.js +0 -833
  49. package/dist/src/types/index.js +0 -0
  50. package/dist/src/utils.js +0 -39
  51. package/src/components/ACell.vue +0 -410
  52. package/src/components/AGanttCell.vue +0 -638
  53. package/src/components/AGanttConnection.vue +0 -164
  54. package/src/components/ARow.vue +0 -251
  55. package/src/components/ARowActions.vue +0 -407
  56. package/src/components/ATable.vue +0 -538
  57. package/src/components/ATableColumnFilter.vue +0 -216
  58. package/src/components/ATableHeader.vue +0 -141
  59. package/src/components/ATableLoading.vue +0 -86
  60. package/src/components/ATableLoadingBar.vue +0 -81
  61. package/src/components/ATableModal.vue +0 -52
  62. package/src/components/ATablePaginationFooter.vue +0 -100
  63. package/src/composables/table-pagination.ts +0 -172
  64. package/src/icons/index.ts +0 -38
  65. package/src/icons/stonecrop-ui-icon-add.svg +0 -5
  66. package/src/icons/stonecrop-ui-icon-delete.svg +0 -5
  67. package/src/icons/stonecrop-ui-icon-duplicate.svg +0 -5
  68. package/src/icons/stonecrop-ui-icon-insert-above.svg +0 -15
  69. package/src/icons/stonecrop-ui-icon-insert-below.svg +0 -15
  70. package/src/icons/stonecrop-ui-icon-move.svg +0 -4
  71. package/src/icons/stonecrop-ui-icon-open.svg +0 -5
  72. package/src/index.ts +0 -62
  73. package/src/linkSearchableText.ts +0 -42
  74. package/src/resolveFilterType.ts +0 -32
  75. package/src/schemaToColumns.ts +0 -67
  76. package/src/shims-vue.d.ts +0 -10
  77. package/src/stores/table.ts +0 -978
  78. package/src/types/index.ts +0 -794
  79. package/src/utils.ts +0 -40
@@ -1,978 +0,0 @@
1
- import { defineStore } from 'pinia'
2
- import { componentCategory } from '@stonecrop/schema'
3
- import type { BadgeDescriptor } from '@stonecrop/schema'
4
- import { type CSSProperties, computed, ref } from 'vue'
5
-
6
- import { linkSearchableText } from '../linkSearchableText'
7
- import type {
8
- CellContext,
9
- ConnectionHandle,
10
- ConnectionPath,
11
- GanttBarInfo,
12
- GanttDragEvent,
13
- TableColumn,
14
- TableConfig,
15
- TableDisplay,
16
- TableModal,
17
- TableRow,
18
- } from '../types'
19
- import { resolveFilterType } from '../resolveFilterType'
20
- import { formatCurrency, formatQuantity, generateHash } from '../utils'
21
-
22
- /**
23
- * Represents the state of a single filter
24
- * @public
25
- */
26
- export interface FilterState {
27
- /** The main filter value */
28
- value: any
29
- /** Start value for date range filters */
30
- startValue?: any
31
- /** End value for date range filters */
32
- endValue?: any
33
- }
34
-
35
- /**
36
- * Record mapping column indices to their filter states
37
- * @public
38
- */
39
- export type FilterStateRecord = Record<number, FilterState>
40
-
41
- // Quantity columns hold a composite `{ qty, uom, ... }` value (see `QuantityValue` in
42
- // `@stonecrop/aform`); numeric comparisons (filtering, sorting) operate on `qty`.
43
- //
44
- // Currency columns hold a composite `{ amount, currency, baseAmount, ... }` value (see
45
- // `CurrencyValue` in `@stonecrop/aform`), and a single column routinely mixes currencies —
46
- // comparing raw `amount` would rank 5 EUR equal to 5 USD and match 100 JPY against a `> 100`
47
- // filter. `baseAmount` is the whole reason the value carries one: it restates every row in the
48
- // record's base currency, which is the only axis on which they are comparable. Fall back to
49
- // `amount` for a value that predates the base conversion (or was authored without one).
50
- function toComparableNumber(cellValue: unknown): number {
51
- if (cellValue !== null && typeof cellValue === 'object') {
52
- if ('qty' in cellValue) return Number(cellValue.qty)
53
- if ('baseAmount' in cellValue && (cellValue as { baseAmount: unknown }).baseAmount != null) {
54
- return Number((cellValue as { baseAmount: unknown }).baseAmount)
55
- }
56
- if ('amount' in cellValue) return Number((cellValue as { amount: unknown }).amount)
57
- }
58
- return Number(cellValue)
59
- }
60
-
61
- function isNodeOpen(rowIndex: number, treeDisplay: TableDisplay[]): boolean {
62
- const row = treeDisplay[rowIndex]
63
- if (row.isRoot) {
64
- return true // Root nodes are always open
65
- }
66
-
67
- if (row.parent === null || row.parent === undefined) {
68
- return true
69
- }
70
-
71
- const parentIndex = row.parent
72
- if (parentIndex < 0 || parentIndex >= treeDisplay.length) {
73
- return false
74
- }
75
-
76
- const parent = treeDisplay[parentIndex]
77
- // Node is open if parent's children are open AND parent itself is open
78
- return (parent.childrenOpen || false) && isNodeOpen(parentIndex, treeDisplay)
79
- }
80
-
81
- function applyFilter(cellValue: any, filter: FilterState, column: TableColumn): boolean {
82
- const filterType = resolveFilterType(column)
83
- const value = filter.value
84
-
85
- if (!value && filterType !== 'dateRange' && filterType !== 'checkbox') return true
86
-
87
- switch (filterType) {
88
- case 'text': {
89
- const searchableText = column.linkDoctype
90
- ? linkSearchableText(cellValue)
91
- : typeof cellValue === 'object' && cellValue !== null
92
- ? Object.values(cellValue).join(' ')
93
- : String(cellValue || '')
94
- return searchableText.toLowerCase().includes(String(value).toLowerCase())
95
- }
96
-
97
- case 'number': {
98
- const numValue = toComparableNumber(cellValue)
99
- const filterNum = Number(value)
100
- return !isNaN(numValue) && !isNaN(filterNum) && numValue === filterNum
101
- }
102
-
103
- case 'select':
104
- return cellValue === value
105
-
106
- case 'checkbox':
107
- // For checkbox filter, if checked (true), show only truthy values
108
- // If unchecked (false/undefined), show all values
109
- if (value === true) {
110
- return !!cellValue
111
- }
112
- return true
113
-
114
- case 'date': {
115
- // Handle both timestamp numbers and date strings
116
- let cellDate: Date
117
- if (typeof cellValue === 'number') {
118
- // Apply the same year transformation as in the format function
119
- const originalDate = new Date(cellValue)
120
- const currentYear = new Date().getFullYear()
121
- cellDate = new Date(currentYear, originalDate.getMonth(), originalDate.getDate())
122
- } else {
123
- cellDate = new Date(String(cellValue))
124
- }
125
- const filterDate = new Date(String(value))
126
- return cellDate.toDateString() === filterDate.toDateString()
127
- }
128
-
129
- case 'dateRange': {
130
- const startValue = filter.startValue
131
- const endValue = filter.endValue
132
- if (!startValue && !endValue) return true
133
-
134
- // Handle both timestamp numbers and date strings
135
- let cellDateRange: Date
136
- if (typeof cellValue === 'number') {
137
- // Apply the same year transformation as in the format function
138
- const originalDate = new Date(cellValue)
139
- const currentYear = new Date().getFullYear()
140
- cellDateRange = new Date(currentYear, originalDate.getMonth(), originalDate.getDate())
141
- } else {
142
- cellDateRange = new Date(String(cellValue))
143
- }
144
- if (startValue && cellDateRange < new Date(String(startValue))) return false
145
- if (endValue && cellDateRange > new Date(String(endValue))) return false
146
-
147
- return true
148
- }
149
-
150
- default:
151
- return true
152
- }
153
- }
154
-
155
- /**
156
- * Compute the CSS indentation for a tree-table cell. Pure helper (captures no store state);
157
- * imported directly by cell components rather than exposed on the store's public return.
158
- * @internal
159
- */
160
- export function getIndent(colIndex: number, indentLevel?: number): string {
161
- if (indentLevel && colIndex === 0 && indentLevel > 0) {
162
- return `${indentLevel}ch`
163
- } else {
164
- return 'inherit'
165
- }
166
- }
167
-
168
- /**
169
- * Create a table store
170
- * @param initData - Initial data for the table store
171
- * @returns table store instance
172
- * @public
173
- */
174
- export const createTableStore = (initData: {
175
- columns: TableColumn[]
176
- rows: TableRow[]
177
- id?: string
178
- config?: TableConfig
179
- modal?: TableModal
180
- linkResolver?: ((doctype: string, id: string) => Promise<string | undefined>) | null
181
- }) => {
182
- const id = initData.id || generateHash()
183
- const linkResolver = initData.linkResolver ?? null
184
- const createStore = defineStore(`table-${id}`, () => {
185
- const createDisplayObject = () => {
186
- const defaultDisplay: TableDisplay[] = [Object.assign({}, { rowModified: false })]
187
-
188
- // TODO: (typing) is this type correct for the parent set?
189
- const parents = new Set<string | number>()
190
- for (let rowIndex = 0; rowIndex < rows.value.length; rowIndex++) {
191
- const row = rows.value[rowIndex]
192
- if (row.parent !== null && row.parent !== undefined) {
193
- parents.add(row.parent)
194
- }
195
- }
196
-
197
- // Helper function to check if a row has gantt data
198
- const hasGanttData = (rowIndex: number): boolean => {
199
- return rows.value[rowIndex]?.gantt !== undefined
200
- }
201
-
202
- // Helper function to check if any descendant has gantt data
203
- const hasGanttDescendant = (rowIndex: number): boolean => {
204
- for (let i = 0; i < rows.value.length; i++) {
205
- if (rows.value[i].parent === rowIndex) {
206
- if (hasGanttData(i) || hasGanttDescendant(i)) {
207
- return true
208
- }
209
- }
210
- }
211
- return false
212
- }
213
-
214
- // Helper function to determine if children should be open based on expansion mode
215
- const shouldChildrenBeOpen = (rowIndex: number): boolean => {
216
- const currentConfig = config.value
217
- const expansionMode =
218
- currentConfig.view === 'tree' || currentConfig.view === 'tree-gantt'
219
- ? currentConfig.defaultTreeExpansion
220
- : undefined
221
-
222
- if (!expansionMode) return true // Default behavior - start expanded (leaf mode)
223
-
224
- switch (expansionMode) {
225
- case 'root':
226
- return false // Only root nodes are visible, all children start collapsed
227
- case 'branch':
228
- // Only expand if this node leads to gantt nodes OR if this node itself has gantt data AND has gantt children
229
- return hasGanttDescendant(rowIndex)
230
- case 'leaf':
231
- return true // All nodes should be expanded
232
- default:
233
- return true // Default to expanded if unknown mode
234
- }
235
- }
236
-
237
- for (let rowIndex = 0; rowIndex < rows.value.length; rowIndex++) {
238
- const row = rows.value[rowIndex]
239
- const isRootNode = row.parent === null || row.parent === undefined
240
- const isParentNode = parents.has(rowIndex)
241
-
242
- defaultDisplay[rowIndex] = {
243
- childrenOpen: shouldChildrenBeOpen(rowIndex),
244
- expanded: false,
245
- indent: row.indent || 0,
246
- isParent: isParentNode,
247
- isRoot: isRootNode,
248
- rowModified: false,
249
- open: isRootNode, // This will be recalculated later for non-root nodes
250
- parent: row.parent,
251
- }
252
- }
253
-
254
- return defaultDisplay
255
- }
256
-
257
- // state
258
- const columns = ref(initData.columns)
259
- const rows = ref(initData.rows)
260
- const config = ref<TableConfig>(initData.config || {})
261
-
262
- // Track row modifications and expand states separately from the computed display
263
- const rowModifications = ref<Record<number, boolean>>({})
264
- const rowExpandStates = ref<Record<number, { childrenOpen?: boolean; expanded?: boolean }>>({})
265
-
266
- const table = computed(() => {
267
- const tableData: Record<string, any> = {}
268
- for (const [colIndex, column] of columns.value.entries()) {
269
- for (const [rowIndex, row] of rows.value.entries()) {
270
- tableData[`${colIndex}:${rowIndex}`] = row[column.name]
271
- }
272
- }
273
- return tableData
274
- })
275
-
276
- const display = computed({
277
- get: () => {
278
- const baseDisplay = createDisplayObject()
279
-
280
- // Apply persistent modifications and expand states
281
- for (let i = 0; i < baseDisplay.length; i++) {
282
- if (rowModifications.value[i]) {
283
- baseDisplay[i].rowModified = rowModifications.value[i]
284
- }
285
- if (rowExpandStates.value[i]) {
286
- if (rowExpandStates.value[i].childrenOpen !== undefined) {
287
- baseDisplay[i].childrenOpen = rowExpandStates.value[i].childrenOpen!
288
- }
289
- if (rowExpandStates.value[i].expanded !== undefined) {
290
- baseDisplay[i].expanded = rowExpandStates.value[i].expanded!
291
- }
292
- }
293
- }
294
-
295
- // Calculate 'open' property for tree view based on parent's childrenOpen state
296
- if (isTreeView.value) {
297
- // Helper function to check if all ancestors are open
298
-
299
- for (let i = 0; i < baseDisplay.length; i++) {
300
- const row = baseDisplay[i]
301
- if (!row.isRoot) {
302
- baseDisplay[i].open = isNodeOpen(i, baseDisplay)
303
- }
304
- }
305
- }
306
-
307
- return baseDisplay
308
- },
309
- set: (newDisplay: TableDisplay[]) => {
310
- // Only update if the new display is different from the current one; also avoids recursive updates
311
- if (JSON.stringify(newDisplay) !== JSON.stringify(display.value)) {
312
- display.value = newDisplay
313
- }
314
- },
315
- })
316
-
317
- const modal = ref<TableModal>(initData.modal || { visible: false })
318
- const updates = ref<Record<string, string>>({})
319
- const ganttBars = ref<GanttBarInfo[]>([])
320
- const connectionHandles = ref<ConnectionHandle[]>([])
321
- const connectionPaths = ref<ConnectionPath[]>([])
322
- const sortState = ref<{ column: number | null; direction: 'asc' | 'desc' | null }>({
323
- column: null,
324
- direction: null,
325
- })
326
- const filterState = ref<FilterStateRecord>({})
327
-
328
- // getters
329
- const hasPinnedColumns = computed(() => columns.value.some(col => col.pinned))
330
- const isGanttView = computed(() => config.value.view === 'gantt' || config.value.view === 'tree-gantt')
331
- const isTreeView = computed(() => config.value.view === 'tree' || config.value.view === 'tree-gantt')
332
- const isDependencyGraphEnabled = computed(() => {
333
- const currentConfig = config.value
334
- return currentConfig.view === 'gantt' || currentConfig.view === 'tree-gantt'
335
- ? currentConfig.dependencyGraph !== false
336
- : true
337
- })
338
-
339
- const numberedRowWidth = computed(() => {
340
- const indent = Math.ceil(rows.value.length / 100 + 1)
341
- return `${indent}ch`
342
- })
343
-
344
- const zeroColumn = computed(() =>
345
- config.value.view ? ['list', 'tree', 'tree-gantt', 'list-expansion'].includes(config.value.view) : false
346
- )
347
-
348
- const filteredRows = computed(() => {
349
- let filtered: Array<TableRow & { originalIndex: number }> = rows.value.map((row, originalIndex) => ({
350
- ...row,
351
- originalIndex,
352
- }))
353
-
354
- // Apply filters
355
- Object.entries(filterState.value).forEach(([colIndexStr, filter]) => {
356
- const colIndex = parseInt(colIndexStr)
357
- const column = columns.value[colIndex]
358
-
359
- if (!column) return
360
-
361
- // Skip if filter has no value (except for dateRange and checkbox which can have different value structures)
362
- const hasFilterValue =
363
- filter.value ||
364
- filter.startValue ||
365
- filter.endValue ||
366
- (resolveFilterType(column) === 'checkbox' && filter.value !== undefined)
367
-
368
- if (!hasFilterValue) return
369
-
370
- filtered = filtered.filter(row => {
371
- const cellValue = row[column.name]
372
- return applyFilter(cellValue, filter, column)
373
- })
374
- })
375
-
376
- // Apply sorting if active
377
- if (sortState.value.column !== null && sortState.value.direction) {
378
- const column = columns.value[sortState.value.column]
379
- const direction = sortState.value.direction
380
-
381
- filtered.sort((a, b) => {
382
- let aVal = a[column.name]
383
- let bVal = b[column.name]
384
-
385
- if (aVal === null || aVal === undefined) aVal = ''
386
- if (bVal === null || bVal === undefined) bVal = ''
387
-
388
- const aNum = toComparableNumber(aVal)
389
- const bNum = toComparableNumber(bVal)
390
- const isNumeric = !isNaN(aNum) && !isNaN(bNum) && aVal !== '' && bVal !== ''
391
-
392
- if (isNumeric) {
393
- return direction === 'asc' ? aNum - bNum : bNum - aNum
394
- } else {
395
- const aStr = String(aVal).toLowerCase()
396
- const bStr = String(bVal).toLowerCase()
397
- return direction === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr)
398
- }
399
- })
400
- }
401
-
402
- return filtered
403
- })
404
-
405
- // actions
406
- const getCellData = (colIndex: number, rowIndex: number): any => table.value[`${colIndex}:${rowIndex}`]
407
- const setCellData = (colIndex: number, rowIndex: number, value: any) => {
408
- const index = `${colIndex}:${rowIndex}`
409
- const col = columns.value[colIndex]
410
-
411
- if (table.value[index] !== value) {
412
- rowModifications.value[rowIndex] = true
413
- }
414
-
415
- table.value[index] = value
416
- // Create a new row object to ensure reactivity
417
- rows.value[rowIndex] = {
418
- ...rows.value[rowIndex],
419
- [col.name]: value,
420
- }
421
- }
422
-
423
- const updateRows = (newRows: TableRow[]) => {
424
- rows.value = newRows
425
- }
426
-
427
- const setCellText = (colIndex: number, rowIndex: number, value: string) => {
428
- const index = `${colIndex}:${rowIndex}`
429
-
430
- if (table.value[index] !== value) {
431
- rowModifications.value[rowIndex] = true
432
- updates.value[index] = value
433
- }
434
- }
435
-
436
- const getHeaderCellStyle = (column: TableColumn): CSSProperties => {
437
- const isLastCol = columns.value.indexOf(column) === columns.value.length - 1
438
-
439
- // if the table is full width, the last column should not be resizable;
440
- // ref: https://github.com/agritheory/stonecrop/pull/196#issuecomment-2503762641
441
-
442
- const isResizable = config.value.fullWidth ? column.resizable && !isLastCol : column.resizable
443
-
444
- return {
445
- width: column.width || '40ch',
446
- textAlign: column.align || 'center',
447
- ...(isResizable && {
448
- resize: 'horizontal',
449
- overflow: 'hidden',
450
- whiteSpace: 'nowrap',
451
- }),
452
- }
453
- }
454
-
455
- const resizeColumn = (colIndex: number, newWidth: number) => {
456
- if (colIndex < 0 || colIndex >= columns.value.length) return
457
-
458
- const minWidth = 40
459
- const finalWidth = Math.max(newWidth, minWidth)
460
-
461
- columns.value[colIndex] = {
462
- ...columns.value[colIndex],
463
- width: `${finalWidth}px`,
464
- }
465
- }
466
-
467
- const isRowGantt = (rowIndex: number) => {
468
- const row = rows.value[rowIndex]
469
- return isGanttView.value && row.gantt !== undefined
470
- }
471
-
472
- const isRowVisible = (rowIndex: number) => {
473
- return !isTreeView.value || display.value[rowIndex].isRoot || display.value[rowIndex].open
474
- }
475
-
476
- const getRowExpandSymbol = (rowIndex: number) => {
477
- if (!isTreeView.value && config.value.view !== 'list-expansion') {
478
- return ''
479
- }
480
-
481
- if (isTreeView.value && (display.value[rowIndex].isRoot || display.value[rowIndex].isParent)) {
482
- return display.value[rowIndex].childrenOpen ? '▼' : '►'
483
- }
484
-
485
- if (config.value.view === 'list-expansion') {
486
- return display.value[rowIndex].expanded ? '▼' : '►'
487
- }
488
-
489
- return ''
490
- }
491
-
492
- const toggleRowExpand = (rowIndex: number) => {
493
- if (isTreeView.value) {
494
- const currentState = rowExpandStates.value[rowIndex] || {}
495
- const currentChildrenOpen = currentState.childrenOpen ?? display.value[rowIndex].childrenOpen
496
- const newChildrenOpen = !currentChildrenOpen
497
-
498
- rowExpandStates.value[rowIndex] = {
499
- ...currentState,
500
- childrenOpen: newChildrenOpen,
501
- }
502
-
503
- // If we're closing, recursively close all descendant nodes
504
- if (!newChildrenOpen) {
505
- closeDescendants(rowIndex)
506
- }
507
- } else if (config.value.view === 'list-expansion') {
508
- const currentState = rowExpandStates.value[rowIndex] || {}
509
- const currentExpanded = currentState.expanded ?? display.value[rowIndex].expanded
510
- rowExpandStates.value[rowIndex] = {
511
- ...currentState,
512
- expanded: !currentExpanded,
513
- }
514
- }
515
- }
516
-
517
- const closeDescendants = (parentRowIndex: number) => {
518
- for (let index = 0; index < rows.value.length; index++) {
519
- if (display.value[index].parent === parentRowIndex) {
520
- const childState = rowExpandStates.value[index] || {}
521
- rowExpandStates.value[index] = {
522
- ...childState,
523
- childrenOpen: false,
524
- }
525
- // Recursively close this child's descendants
526
- closeDescendants(index)
527
- }
528
- }
529
- }
530
-
531
- const getCellDisplayValue = (colIndex: number, rowIndex: number) => {
532
- const cellData = getCellData(colIndex, rowIndex)
533
- return getFormattedValue(colIndex, rowIndex, cellData)
534
- }
535
-
536
- const getFormattedValue = (colIndex: number, rowIndex: number, value: any) => {
537
- const column = columns.value[colIndex]
538
- const row = rows.value[rowIndex]
539
- const format = column.format
540
-
541
- if (!format) {
542
- // Default cell formatting comes from the component's category; anything without an
543
- // opinion (including an unknown component) renders the raw value.
544
- const category = componentCategory(column.component)
545
- if (category === 'boolean') return value ? '✓' : '✗'
546
- if (category === 'date') return value != null ? new Date(String(value)).toLocaleDateString() : value
547
- if (category === 'datetime') return value != null ? new Date(String(value)).toLocaleString() : value
548
- if (category === 'quantity') return formatQuantity(value)
549
- if (category === 'currency') return formatCurrency(value)
550
- return value
551
- }
552
-
553
- if (typeof format === 'function') {
554
- return format(value, { table: table.value, row, column })
555
- } else if (typeof format === 'string') {
556
- // parse format function from string. The alias keeps the declaration on one line so the
557
- // disable directive below stays attached to the Function call it covers.
558
- type DeserializedFormat = (value: any, context?: CellContext) => string | BadgeDescriptor
559
- // oxlint-disable-next-line @typescript-eslint/no-implied-eval
560
- const formatFn: DeserializedFormat = Function(`"use strict";return (${format})`)()
561
- return formatFn(value, { table: table.value, row, column })
562
- }
563
-
564
- return value
565
- }
566
-
567
- const closeModal = (event: MouseEvent) => {
568
- if (!(event.target instanceof Node)) {
569
- // if the target is not a node, it's probably a custom click event to Document or Window
570
- // err on the side of closing the modal in that case
571
- if (modal.value.visible) modal.value.visible = false
572
- } else if (!modal.value.parent?.contains(event.target)) {
573
- if (modal.value.visible) modal.value.visible = false
574
- }
575
- }
576
-
577
- const updateGanttBar = (event: GanttDragEvent) => {
578
- // update the local gantt bar cache
579
- const ganttBar = rows.value[event.rowIndex]?.gantt
580
- if (ganttBar) {
581
- if (event.type === 'resize') {
582
- if (event.edge === 'start') {
583
- ganttBar.startIndex = event.newStart
584
- ganttBar.endIndex = event.end
585
- ganttBar.colspan = ganttBar.endIndex - ganttBar.startIndex
586
- } else if (event.edge === 'end') {
587
- ganttBar.startIndex = event.start
588
- ganttBar.endIndex = event.newEnd
589
- ganttBar.colspan = ganttBar.endIndex - ganttBar.startIndex
590
- }
591
- } else if (event.type === 'bar') {
592
- ganttBar.startIndex = event.newStart
593
- ganttBar.endIndex = event.newEnd
594
- ganttBar.colspan = ganttBar.endIndex - ganttBar.startIndex
595
- }
596
- }
597
- }
598
-
599
- const registerGanttBar = (barInfo: GanttBarInfo) => {
600
- const existingIndex = ganttBars.value.findIndex(bar => bar.id === barInfo.id)
601
- if (existingIndex >= 0) {
602
- // @ts-expect-error TODO: for some reason, the IDE is expecting an unref'd value
603
- ganttBars.value[existingIndex] = barInfo
604
- } else {
605
- // @ts-expect-error TODO: for some reason, the IDE is expecting an unref'd value
606
- ganttBars.value.push(barInfo)
607
- }
608
- }
609
-
610
- const unregisterGanttBar = (barId: string) => {
611
- const index = ganttBars.value.findIndex(bar => bar.id === barId)
612
- if (index >= 0) {
613
- ganttBars.value.splice(index, 1)
614
- }
615
- }
616
-
617
- const registerConnectionHandle = (handleInfo: ConnectionHandle) => {
618
- const existingIndex = connectionHandles.value.findIndex(handle => handle.id === handleInfo.id)
619
- if (existingIndex >= 0) {
620
- // @ts-expect-error TODO: for some reason, the IDE is expecting an unref'd value
621
- connectionHandles.value[existingIndex] = handleInfo
622
- } else {
623
- // @ts-expect-error TODO: for some reason, the IDE is expecting an unref'd value
624
- connectionHandles.value.push(handleInfo)
625
- }
626
- }
627
-
628
- const unregisterConnectionHandle = (handleId: string) => {
629
- const index = connectionHandles.value.findIndex(handle => handle.id === handleId)
630
- if (index >= 0) {
631
- connectionHandles.value.splice(index, 1)
632
- }
633
- }
634
-
635
- const createConnection = (
636
- fromHandleId: string,
637
- toHandleId: string,
638
- options?: { style?: ConnectionPath['style']; label?: string }
639
- ) => {
640
- const fromHandle = connectionHandles.value.find(h => h.id === fromHandleId)
641
- const toHandle = connectionHandles.value.find(h => h.id === toHandleId)
642
-
643
- if (!fromHandle || !toHandle) {
644
- console.warn('Cannot create connection: handle not found')
645
- return null
646
- }
647
-
648
- const connection: ConnectionPath = {
649
- id: `connection-${fromHandleId}-${toHandleId}`,
650
- from: {
651
- barId: fromHandle.barId,
652
- side: fromHandle.side,
653
- },
654
- to: {
655
- barId: toHandle.barId,
656
- side: toHandle.side,
657
- },
658
- style: options?.style,
659
- label: options?.label,
660
- }
661
-
662
- connectionPaths.value.push(connection)
663
- return connection
664
- }
665
-
666
- const deleteConnection = (connectionId: string) => {
667
- const index = connectionPaths.value.findIndex(conn => conn.id === connectionId)
668
- if (index >= 0) {
669
- connectionPaths.value.splice(index, 1)
670
- return true
671
- }
672
- return false
673
- }
674
-
675
- const getConnectionsForBar = (barId: string) => {
676
- return connectionPaths.value.filter(conn => conn.from.barId === barId || conn.to.barId === barId)
677
- }
678
-
679
- const getHandlesForBar = (barId: string) => {
680
- return connectionHandles.value.filter(handle => handle.barId === barId)
681
- }
682
-
683
- const sortByColumn = (colIndex: number) => {
684
- const column = columns.value[colIndex]
685
- if (column.sortable === false) return
686
-
687
- let newDirection: 'asc' | 'desc'
688
- if (sortState.value.column === colIndex) {
689
- if (sortState.value.direction === 'asc') {
690
- newDirection = 'desc'
691
- } else {
692
- newDirection = 'asc'
693
- }
694
- } else {
695
- newDirection = 'asc'
696
- }
697
-
698
- sortState.value.column = colIndex
699
- sortState.value.direction = newDirection
700
-
701
- // Note: The actual sorting is now handled in the filteredRows computed property
702
- // This ensures that sorting works on filtered data without modifying the original rows
703
- }
704
-
705
- const setFilter = (colIndex: number, filter: FilterState) => {
706
- if (!filter.value && !filter.startValue && !filter.endValue) {
707
- // Remove filter if empty
708
- delete filterState.value[colIndex]
709
- } else {
710
- filterState.value[colIndex] = filter
711
- }
712
- }
713
-
714
- const clearFilter = (colIndex: number) => {
715
- delete filterState.value[colIndex]
716
- }
717
-
718
- // Row action methods
719
-
720
- /**
721
- * Add a new row to the table.
722
- * @param rowData - Optional partial row data to initialize the new row with
723
- * @param position - Where to insert the row: 'start', 'end', or a specific index
724
- * @returns The index of the newly added row
725
- */
726
- const addRow = (rowData?: Partial<TableRow>, position: 'start' | 'end' | number = 'end'): number => {
727
- // Create a new row with default empty values for each column
728
- const newRow: TableRow = {}
729
- for (const column of columns.value) {
730
- newRow[column.name] = ''
731
- }
732
-
733
- // Merge in any provided row data
734
- if (rowData) {
735
- Object.assign(newRow, rowData)
736
- }
737
-
738
- let insertIndex: number
739
- if (position === 'start') {
740
- insertIndex = 0
741
- rows.value.unshift(newRow)
742
- } else if (position === 'end') {
743
- insertIndex = rows.value.length
744
- rows.value.push(newRow)
745
- } else {
746
- insertIndex = Math.max(0, Math.min(position, rows.value.length))
747
- rows.value.splice(insertIndex, 0, newRow)
748
- }
749
-
750
- return insertIndex
751
- }
752
-
753
- /**
754
- * Delete a row from the table.
755
- * @param rowIndex - The index of the row to delete
756
- * @returns The deleted row, or null if the index was invalid
757
- */
758
- const deleteRow = (rowIndex: number): TableRow | null => {
759
- if (rowIndex < 0 || rowIndex >= rows.value.length) {
760
- return null
761
- }
762
-
763
- const [deletedRow] = rows.value.splice(rowIndex, 1)
764
-
765
- // Clean up row modifications tracking for the deleted row
766
- delete rowModifications.value[rowIndex]
767
- delete rowExpandStates.value[rowIndex]
768
-
769
- // Shift modification/expand state indices for rows after the deleted one
770
- const newModifications: Record<number, boolean> = {}
771
- const newExpandStates: Record<number, { childrenOpen?: boolean; expanded?: boolean }> = {}
772
-
773
- for (const [key, value] of Object.entries(rowModifications.value)) {
774
- const idx = parseInt(key)
775
- if (idx > rowIndex) {
776
- newModifications[idx - 1] = value
777
- } else {
778
- newModifications[idx] = value
779
- }
780
- }
781
-
782
- for (const [key, value] of Object.entries(rowExpandStates.value)) {
783
- const idx = parseInt(key)
784
- if (idx > rowIndex) {
785
- newExpandStates[idx - 1] = value
786
- } else {
787
- newExpandStates[idx] = value
788
- }
789
- }
790
-
791
- rowModifications.value = newModifications
792
- rowExpandStates.value = newExpandStates
793
-
794
- return deletedRow
795
- }
796
-
797
- /**
798
- * Duplicate a row in the table.
799
- * @param rowIndex - The index of the row to duplicate
800
- * @returns The index of the new duplicated row, or -1 if the index was invalid
801
- */
802
- const duplicateRow = (rowIndex: number): number => {
803
- if (rowIndex < 0 || rowIndex >= rows.value.length) {
804
- return -1
805
- }
806
-
807
- // Deep clone the row data
808
- const originalRow = rows.value[rowIndex]
809
- const duplicatedRow: TableRow = JSON.parse(JSON.stringify(originalRow))
810
-
811
- // Insert the duplicated row after the original
812
- const newIndex = rowIndex + 1
813
- rows.value.splice(newIndex, 0, duplicatedRow)
814
-
815
- return newIndex
816
- }
817
-
818
- /**
819
- * Insert a new row above the specified row.
820
- * @param rowIndex - The index of the row to insert above
821
- * @param rowData - Optional partial row data to initialize the new row with
822
- * @returns The index of the newly inserted row
823
- */
824
- const insertRowAbove = (rowIndex: number, rowData?: Partial<TableRow>): number => {
825
- const insertIndex = Math.max(0, rowIndex)
826
- return addRow(rowData, insertIndex)
827
- }
828
-
829
- /**
830
- * Insert a new row below the specified row.
831
- * @param rowIndex - The index of the row to insert below
832
- * @param rowData - Optional partial row data to initialize the new row with
833
- * @returns The index of the newly inserted row
834
- */
835
- const insertRowBelow = (rowIndex: number, rowData?: Partial<TableRow>): number => {
836
- const insertIndex = Math.min(rowIndex + 1, rows.value.length)
837
- return addRow(rowData, insertIndex)
838
- }
839
-
840
- /**
841
- * Move a row from one position to another.
842
- * @param fromIndex - The current index of the row to move
843
- * @param toIndex - The target index to move the row to
844
- * @returns true if the move was successful, false otherwise
845
- */
846
- const moveRow = (fromIndex: number, toIndex: number): boolean => {
847
- // Validate indices
848
- if (
849
- fromIndex < 0 ||
850
- fromIndex >= rows.value.length ||
851
- toIndex < 0 ||
852
- toIndex >= rows.value.length ||
853
- fromIndex === toIndex
854
- ) {
855
- return false
856
- }
857
-
858
- // Remove the row from its current position
859
- const [movedRow] = rows.value.splice(fromIndex, 1)
860
-
861
- // Insert at the new position
862
- rows.value.splice(toIndex, 0, movedRow)
863
-
864
- // Update row modification and expand state indices
865
- const newModifications: Record<number, boolean> = {}
866
- const newExpandStates: Record<number, { childrenOpen?: boolean; expanded?: boolean }> = {}
867
-
868
- for (const [key, value] of Object.entries(rowModifications.value)) {
869
- const idx = parseInt(key)
870
- let newIdx = idx
871
-
872
- if (idx === fromIndex) {
873
- // The moved row
874
- newIdx = toIndex
875
- } else if (fromIndex < toIndex) {
876
- // Moving down: rows between fromIndex and toIndex shift up
877
- if (idx > fromIndex && idx <= toIndex) {
878
- newIdx = idx - 1
879
- }
880
- } else {
881
- // Moving up: rows between toIndex and fromIndex shift down
882
- if (idx >= toIndex && idx < fromIndex) {
883
- newIdx = idx + 1
884
- }
885
- }
886
-
887
- newModifications[newIdx] = value
888
- }
889
-
890
- for (const [key, value] of Object.entries(rowExpandStates.value)) {
891
- const idx = parseInt(key)
892
- let newIdx = idx
893
-
894
- if (idx === fromIndex) {
895
- newIdx = toIndex
896
- } else if (fromIndex < toIndex) {
897
- if (idx > fromIndex && idx <= toIndex) {
898
- newIdx = idx - 1
899
- }
900
- } else {
901
- if (idx >= toIndex && idx < fromIndex) {
902
- newIdx = idx + 1
903
- }
904
- }
905
-
906
- newExpandStates[newIdx] = value
907
- }
908
-
909
- rowModifications.value = newModifications
910
- rowExpandStates.value = newExpandStates
911
-
912
- return true
913
- }
914
-
915
- return {
916
- // state
917
- columns,
918
- config,
919
- connectionHandles,
920
- connectionPaths,
921
- display,
922
- filterState,
923
- ganttBars,
924
- modal,
925
- rows,
926
- sortState,
927
- table,
928
- updates,
929
-
930
- // getters
931
- filteredRows,
932
- hasPinnedColumns,
933
- isGanttView,
934
- isTreeView,
935
- isDependencyGraphEnabled,
936
- numberedRowWidth,
937
- zeroColumn,
938
-
939
- // resolver
940
- linkResolver,
941
-
942
- // actions
943
- addRow,
944
- clearFilter,
945
- closeModal,
946
- createConnection,
947
- deleteConnection,
948
- deleteRow,
949
- duplicateRow,
950
- getCellData,
951
- getCellDisplayValue,
952
- getConnectionsForBar,
953
- getFormattedValue,
954
- getHandlesForBar,
955
- getHeaderCellStyle,
956
- getRowExpandSymbol,
957
- insertRowAbove,
958
- insertRowBelow,
959
- isRowGantt,
960
- isRowVisible,
961
- moveRow,
962
- registerConnectionHandle,
963
- registerGanttBar,
964
- resizeColumn,
965
- setCellData,
966
- setCellText,
967
- setFilter,
968
- sortByColumn,
969
- toggleRowExpand,
970
- unregisterConnectionHandle,
971
- unregisterGanttBar,
972
- updateGanttBar,
973
- updateRows,
974
- }
975
- })
976
-
977
- return createStore()
978
- }