@isoftdata/svelte-table 2.8.2 → 2.9.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.
package/dist/Table.svelte CHANGED
@@ -1,1072 +1,1074 @@
1
- <script module>
2
- import { getBootstrapCdnVersion } from '@isoftdata/utility-bootstrap'
3
- let bs5 = $state(false)
4
- try {
5
- bs5 = getBootstrapCdnVersion() === 5
6
- } catch (e) {
7
- onMount(() => {
8
- bs5 = getBootstrapCdnVersion() === 5
9
- })
10
- }
11
- </script>
12
-
13
- <script
14
- lang="ts"
15
- generics="R extends UuidRowProps"
16
- >
17
- import type { Snippet } from 'svelte'
18
-
19
- import { ColumnInfoRunicStore } from './column-info-store.svelte'
20
-
21
- import type { i18n } from 'i18next'
22
- import type { Get } from 'type-fest'
23
- import {
24
- type Column as GenericColumn,
25
- type SortDirection,
26
- type UuidRowProps,
27
- type RowProperties,
28
- type FooterReducerValue,
29
- ARBITRARY_PROPERTY_PREFIX,
30
- type ArbitraryProperty,
31
- } from './'
32
-
33
- import { writable, type Writable } from 'svelte/store'
34
-
35
- import Pagination from './Pagination.svelte'
36
- import Td from './Td.svelte'
37
- import Input from '@isoftdata/svelte-input'
38
- import ContextMenu, { DropdownItem } from '@isoftdata/svelte-context-menu'
39
- import { v4 as uuid } from '@lukeed/uuid'
40
- import { format as formatCurrency } from '@isoftdata/utility-currency'
41
- import { setContext, tick, onMount, getContext, untrack } from 'svelte'
42
- import { on } from 'svelte/events'
43
- import naturalCompare from 'natural-compare-lite'
44
- import type { HTMLTableAttributes, ClassValue } from 'svelte/elements'
45
- import { translate as defaultTranslate } from '@isoftdata/utility-string'
46
-
47
- const { t: translate } = getContext<i18n>('i18next') || { t: defaultTranslate }
48
-
49
- type K = keyof R
50
- type KeyPath = RowProperties<R>
51
- type ColumnProperty = KeyPath | ArbitraryProperty
52
-
53
- type IndexedRow = R & { originalIndex: number; uuid: string }
54
- type GetRowProperty = Get<R, KeyPath>
55
- type GetIndexedRowProperty = Get<IndexedRow, RowProperties<IndexedRow>>
56
- type FooterValue = {
57
- property: ColumnProperty
58
- value: FooterReducerValue<R>
59
- }
60
- // Type hack so passing an array of columns without the type argument doesn't reduce the type of R to any
61
- type Column = GenericColumn<R & {}>
62
-
63
- interface Props extends Omit<HTMLTableAttributes, 'children' | `aria-${string}` | `bind:${string}` | `on:${string}`> {
64
- filterEnabled?: boolean
65
- columns: Array<Column>
66
- tableId?: string
67
- rows: Array<R>
68
- sortDirection?: SortDirection
69
- sortColumn?: Column | undefined
70
- class?: ClassValue
71
- perPageCount?: number
72
- currentPageNumber?: number
73
- showFooter?: boolean
74
- footers?: Array<FooterValue>
75
- lazySort?: boolean //when true, the table can only be resorted when the user clicks on a column header(not real time as data changes)
76
- idProp?: K | 'uuid' //required for lazy sorting.
77
- previousSortOrder?: Array<IndexedRow & { order: number }>
78
- filterProps?: Array<KeyPath> | false
79
- filter?: string
80
- filterLabel?: string
81
- filterPlaceholder?: string
82
- filterDisabled?: boolean
83
- filterReadonly?: boolean
84
- filterLazy?: boolean | number
85
- showFilterLabel?: boolean
86
- headerRowClass?: ClassValue
87
- headerColumnClass?: ClassValue
88
- filterColumnClass?: ClassValue
89
- columnHidingEnabled?: boolean
90
- selectionEnabled?: boolean
91
- // Array<number> | Array<string> | Array<R[K]> ???
92
- selectedRowIds?: Array<GetIndexedRowProperty>
93
- rowSelectionIdProp?: KeyPath | 'uuid'
94
- rowSelectionRequiresModKey?: boolean
95
- selectionMode?: 'SINGLE' | 'RANGE' | null
96
- multiSelectEnabled?: boolean
97
- lastClickedIdForRange?: GetIndexedRowProperty | undefined
98
- totalItemsCount?: number
99
- stickyHeader?: boolean
100
- responsive?: boolean | 'sm' | 'md' | 'lg' | 'xl'
101
- size?: 'sm' | ''
102
- striped?: boolean
103
- /** When true, enables tree-specific features. Required if you want to use `TreeRow`s. */
104
- tree?: boolean
105
- bordered?: boolean
106
- hover?: boolean
107
- previousSortColumn?: Column | undefined
108
- previousSortDirection?: SortDirection | undefined
109
- parentClass?: ClassValue
110
- parentStyle?: string
111
- hideButtonClass?: ClassValue
112
- columnResizingEnabled?: boolean
113
- /** If specified, where to serialize the columnInfo store */
114
- localStorageKey?: string | undefined
115
- columnClickedMethod?: typeof defaultColumnClicked
116
- filterMethod?: typeof defaultFilter
117
- rowMatchesFilterMethod?: typeof defaultRowMatchesFilter
118
- filteredRows?: Array<IndexedRow>
119
- sortedRows?: Array<IndexedRow>
120
- currentPageRows?: Array<IndexedRow>
121
- // snippets
122
- header?: Snippet
123
- body?: Snippet<[{ rows: Array<R & { originalIndex: number; uuid: string }>; visibleColumnsCount: number }]>
124
- children?: Snippet<[{ row: R & { originalIndex: number; uuid: string }; index: number }]>
125
- noRows?: Snippet<[{ visibleColumnsCount: number }]>
126
- footerRow?: Snippet<[{ footers: Array<FooterValue> }]>
127
- // callbacks
128
- pageChange?: (context: { pageNumber: number }) => void
129
- columnResizeEnd?: (context: { column: Column }) => void
130
- }
131
-
132
- const uid = $props.id()
133
-
134
- let {
135
- filterEnabled = false,
136
- columns,
137
- tableId = uid,
138
- rows,
139
- sortDirection = $bindable('ASC'),
140
- sortColumn = $bindable(undefined),
141
- class: tableClass = '',
142
- perPageCount = 0,
143
- currentPageNumber = $bindable(1),
144
- showFooter = false,
145
- lazySort = false,
146
- idProp = 'uuid',
147
- previousSortOrder = $bindable([]),
148
- filterProps = false,
149
- filter = $bindable(''),
150
- filterLabel = translate('common:filter', 'Filter'),
151
- filterPlaceholder = translate('common:filter', 'Filter'),
152
- filterDisabled = false,
153
- filterReadonly = false,
154
- filterLazy = false,
155
- showFilterLabel = false,
156
- headerRowClass = bs5 ? 'row' : 'form-row',
157
- headerColumnClass = 'col',
158
- filterColumnClass = 'col-lg-2 col-md-4 col-sm-6 align-self-end',
159
- columnHidingEnabled = false,
160
- selectionEnabled = false,
161
- selectedRowIds = $bindable([]),
162
- rowSelectionIdProp = 'uuid',
163
- rowSelectionRequiresModKey = false,
164
- selectionMode = $bindable(null),
165
- multiSelectEnabled = true,
166
- lastClickedIdForRange = $bindable(undefined),
167
- totalItemsCount = 0,
168
- stickyHeader = false,
169
- responsive = false,
170
- size = 'sm',
171
- striped = true,
172
- tree = false,
173
- bordered = true,
174
- hover = true,
175
- previousSortColumn = $bindable(undefined),
176
- previousSortDirection = $bindable(undefined),
177
- parentClass = '',
178
- parentStyle = '',
179
- hideButtonClass = '',
180
- columnResizingEnabled = false,
181
- localStorageKey = undefined,
182
- columnClickedMethod = defaultColumnClicked,
183
- filterMethod = defaultFilter,
184
- rowMatchesFilterMethod = defaultRowMatchesFilter,
185
- filteredRows = $bindable(filterMethod('', rows, columns)),
186
- sortedRows = $bindable(filteredRows),
187
- currentPageRows = $bindable(sortedRows.slice(0, perPageCount)),
188
- // snippets
189
- header,
190
- body,
191
- children,
192
- noRows,
193
- footerRow,
194
- // callbacks
195
- pageChange,
196
- columnResizeEnd,
197
- ...rest
198
- }: Props = $props()
199
-
200
- let lastPageNumber: number = $state(1)
201
- let paginationComponent: Pagination<IndexedRow> | undefined = $state()
202
- let contextMenu: ContextMenu | undefined = $state()
203
- let contextMenuColumn: Column | undefined = $state()
204
- let columnResizingIsActive: boolean = $state(false)
205
- /** Set to true after getting initial table widths when resizing enabled*/
206
- let useFixedLayout = $state(false)
207
- let tableParent: HTMLDivElement | undefined = $state()
208
-
209
- // Do the initial sort if they specified a default sort column
210
- const defaultSortColumn = columns.find(column => column.defaultSortColumn)
211
- if (defaultSortColumn) {
212
- sortColumn = defaultSortColumn
213
- sortDirection = defaultSortColumn.defaultSortDirection || 'ASC'
214
-
215
- doSort({
216
- rows: filteredRows,
217
- sortColumn,
218
- sortDirection,
219
- sameSortOrder: false,
220
- })
221
- }
222
-
223
- // Need to track which rows are expanded so when the rows reorder, they correctly keep that state.
224
- const expandedRows = writable<Record<number | string, boolean>>({})
225
- if (tree) {
226
- setContext('expandedRows', expandedRows)
227
- }
228
-
229
- // TODO: bad things will happen if we update any of our apps to use Runes for the store if we don't account for that here
230
- const session = getContext<Writable<{ userAccountId?: number }> | undefined>('session')
231
- // This will be accessible on the component instance, ie `const columnInfo = $derived(table?.columnInfo)` if you need it
232
- export const columnInfo = new ColumnInfoRunicStore<R>(() => columns, {
233
- key: localStorageKey,
234
- userAccountId: session && $session ? $session.userAccountId : undefined,
235
- })
236
- // Set as context so we can retrieve it in the Td component
237
- setContext('columnInfo', columnInfo)
238
- // Set as context so we can retrieve it in the TreeRow component
239
- const selectedRowIdsStore = writable(selectedRowIds)
240
- setContext('selectedRowIds', selectedRowIdsStore)
241
- setContext('idProp', idProp)
242
- setContext('columnResizingEnabled', writable(columnResizingEnabled))
243
- setContext('bs5', bs5)
244
-
245
- // #endregion
246
- // #region Functions
247
-
248
- function isArbitraryProperty(property: string | undefined): property is ArbitraryProperty {
249
- return !!property?.startsWith(ARBITRARY_PROPERTY_PREFIX)
250
- }
251
-
252
- export async function defaultColumnClicked(clickedColumn: Column, sortDirection: SortDirection) {
253
- if (currentPageNumber !== lastPageNumber) {
254
- paginationComponent?.setPageNumber(1)
255
- }
256
-
257
- doSort({
258
- rows: filteredRows,
259
- sortColumn: clickedColumn,
260
- sortDirection,
261
- sameSortOrder: false,
262
- })
263
- }
264
-
265
- function defaultFilter(filter: string, rows: R[], columns: Column[]): IndexedRow[] {
266
- const columnProps = columns
267
- ?.map(({ property }) => property)
268
- .filter((property): property is KeyPath => !isArbitraryProperty(property))
269
-
270
- if (filterEnabled) {
271
- const props = filterProps || columnProps
272
- return getFilterMatches(filter, rows, props)
273
- }
274
-
275
- return rows.map((row, index): IndexedRow => ({ ...row, originalIndex: index, uuid: row.uuid || uuid() }))
276
- }
277
-
278
- /** In normal mode, returns all rows matching the filter.
279
- *
280
- * In tree mode, shows all children if parent matches, or shows matching children and their children
281
- */
282
- function getFilterMatches(filter: string, theRows: Array<R>, props: KeyPath[]) {
283
- return theRows.reduce((rows, row, index): IndexedRow[] => {
284
- if (rowMatchesFilterMethod(filter, row, props)) {
285
- rows.push({ ...row, originalIndex: index, uuid: 'uuid' in row ? (row.uuid as string) : uuid() })
286
- } else if (tree && Array.isArray(row.children) && row.children.length) {
287
- const children = getFilterMatches(filter, row.children, props)
288
- if (children.length) {
289
- rows.push({ ...row, children, originalIndex: index, uuid: 'uuid' in row ? (row.uuid as string) : uuid() })
290
- }
291
- }
292
-
293
- return rows
294
- }, new Array<IndexedRow>())
295
- }
296
-
297
- export function defaultRowMatchesFilter(filter: string, row: R, props: Array<RowProperties<R>>) {
298
- return (
299
- !filter ||
300
- props.some(prop => {
301
- const value = getNestedProperty(row, prop)
302
- if (typeof value === 'string' || typeof value === 'number') {
303
- return value.toString().toUpperCase().indexOf(filter.toUpperCase()) > -1
304
- }
305
- return false
306
- })
307
- )
308
- }
309
-
310
- export function getNestedProperty(
311
- row: R & { uuid: string },
312
- path: KeyPath | 'uuid',
313
- defaultValue?: string | number,
314
- ): GetIndexedRowProperty
315
- export function getNestedProperty(row: R, path: KeyPath, defaultValue?: string | number): GetRowProperty
316
- export function getNestedProperty(
317
- row: R | (R & { uuid: string }),
318
- path: KeyPath | 'uuid',
319
- defaultValue?: string | number,
320
- ): GetRowProperty | GetIndexedRowProperty {
321
- if (!path) {
322
- return defaultValue as GetRowProperty
323
- }
324
-
325
- if (row[path]) {
326
- return row[path] as GetRowProperty
327
- }
328
-
329
- const val =
330
- path
331
- .split('[')
332
- // TODO figure out if we can do this less evilly
333
- .reduce((obj, prop) => (obj as any)?.[prop.replace(/\]/g, '')], row as any) ?? defaultValue
334
- return val
335
- }
336
-
337
- async function columnClickHandler(clickedColumn: Column) {
338
- if (clickedColumn.sortType === false || columnResizingIsActive || isArbitraryProperty(clickedColumn.property)) {
339
- return
340
- }
341
- sortDirection = sortDirection === 'ASC' && clickedColumn.property === sortColumn?.property ? 'DESC' : 'ASC'
342
-
343
- columnClickedMethod(clickedColumn, sortDirection)
344
-
345
- sortColumn = clickedColumn
346
- await tick()
347
- }
348
-
349
- export function expandRow(rowId: string | number, expanded = true) {
350
- if (tree) {
351
- $expandedRows[rowId] = expanded
352
- } else {
353
- console.warn('expandRow should only be used when the `tree` prop is set to true on the Table component.')
354
- }
355
- }
356
-
357
- export function rowIsSelected(
358
- row: IndexedRow,
359
- rowSelectionIdProp: KeyPath | 'uuid',
360
- selectedRowIds: Array<GetIndexedRowProperty>,
361
- ) {
362
- const selectionId = getNestedProperty(row, rowSelectionIdProp, row.uuid)
363
- return selectedRowIds.includes(selectionId)
364
- }
365
-
366
- export function rowClick(row: IndexedRow) {
367
- if (!selectionEnabled || !rowSelectionIdProp) {
368
- return
369
- }
370
- const clickedId = getNestedProperty(row, rowSelectionIdProp || 'uuid')
371
-
372
- const selectedIds = selectedRowIds
373
-
374
- let newSelectedIds: Array<GetIndexedRowProperty> = []
375
-
376
- if (!selectionMode && !rowSelectionRequiresModKey) {
377
- selectionMode = 'SINGLE'
378
- }
379
-
380
- if (selectionMode === 'SINGLE') {
381
- newSelectedIds = selectedIds
382
- const foundInExistingSelections = selectedIds.some(id => id === clickedId)
383
-
384
- if (foundInExistingSelections) {
385
- newSelectedIds = newSelectedIds.filter(id => id !== clickedId)
386
- } else if (multiSelectEnabled) {
387
- newSelectedIds = newSelectedIds.concat(clickedId)
388
- } else {
389
- newSelectedIds = [clickedId]
390
- }
391
- } else if (selectionMode === 'RANGE') {
392
- if (selectedIds.length < 1) {
393
- newSelectedIds = [clickedId]
394
- } else {
395
- newSelectedIds = selectedIds
396
- const clickedItemIndex = sortedRows.findIndex(item => {
397
- return item[rowSelectionIdProp] === clickedId
398
- })
399
-
400
- const lastSelectedIndex = sortedRows.findIndex(item => {
401
- return item[rowSelectionIdProp] === lastClickedIdForRange
402
- })
403
-
404
- let selectionDirection: 'UP' | 'DOWN' | undefined
405
-
406
- if (lastSelectedIndex > clickedItemIndex) {
407
- selectionDirection = 'UP'
408
- } else if (lastSelectedIndex < clickedItemIndex) {
409
- selectionDirection = 'DOWN'
410
- }
411
-
412
- if (selectionDirection) {
413
- sortedRows.forEach((item, index) => {
414
- if (selectionDirection === 'UP' && index <= lastSelectedIndex && index >= clickedItemIndex) {
415
- newSelectedIds = newSelectedIds.concat(getNestedProperty(item, rowSelectionIdProp))
416
- } else if (selectionDirection === 'DOWN' && index >= lastSelectedIndex && index <= clickedItemIndex) {
417
- newSelectedIds = newSelectedIds.concat(getNestedProperty(item, rowSelectionIdProp))
418
- }
419
- })
420
- }
421
- }
422
- } else {
423
- return
424
- }
425
-
426
- const clickedIdWasAdded = newSelectedIds.find(id => id === clickedId)
427
-
428
- let lastClickedId: GetIndexedRowProperty | undefined
429
-
430
- if (newSelectedIds.length === 0) {
431
- lastClickedId = undefined
432
- } else if (selectionMode === 'SINGLE' && clickedIdWasAdded) {
433
- lastClickedId = clickedId
434
- } else {
435
- lastClickedId = lastClickedIdForRange
436
- }
437
-
438
- selectedRowIds = [...new Set(newSelectedIds)]
439
- lastClickedIdForRange = lastClickedId
440
- }
441
- export function setPageVisibleByItemIndex(index: number) {
442
- if (index > -1) {
443
- paginationComponent?.setPageVisibleByItemIndex(index)
444
- }
445
- }
446
- export function setPageVisibleByItemId({ id, keyName }: { id: number | string; keyName: K }) {
447
- const index = sortedRows.findIndex(item => item[keyName] == id)
448
-
449
- if (index > -1) {
450
- paginationComponent?.setPageVisibleByItemIndex(index)
451
- }
452
- }
453
- export function doSort({
454
- rows,
455
- sortColumn,
456
- sortDirection,
457
- sameSortOrder,
458
- }: {
459
- rows: Array<IndexedRow>
460
- sortColumn: Column | undefined
461
- sortDirection: SortDirection
462
- sameSortOrder: boolean
463
- }) {
464
- const keepSameSortOrder = !!(lazySort && sameSortOrder && idProp)
465
- const hasPreviousSort = previousSortColumn && previousSortOrder.length > 0
466
-
467
- let sortProp: KeyPath
468
- if (
469
- keepSameSortOrder &&
470
- hasPreviousSort &&
471
- previousSortColumn &&
472
- idProp &&
473
- !isArbitraryProperty(previousSortColumn.property)
474
- ) {
475
- sortColumn = previousSortColumn
476
- sortProp = previousSortColumn.property
477
- sortDirection = 'ASC' //force ASC for keeping the same order because we defined the order in previousSortOrder
478
-
479
- rows = rows.map(row => {
480
- const foundItem = previousSortOrder.find(
481
- previousSortOrderRow => !!idProp && previousSortOrderRow[idProp] === row[idProp],
482
- )
483
-
484
- return { ...row, order: foundItem ? foundItem.order : 0 }
485
- })
486
- } else if (sortColumn?.property && !isArbitraryProperty(sortColumn.property)) {
487
- sortProp = sortColumn.property
488
- } else {
489
- // no sort property, can't sort
490
- sortedRows = rows
491
- return
492
- }
493
-
494
- function doTheSort(theRows: Array<IndexedRow>): Array<IndexedRow> {
495
- if (tree) {
496
- return theRows
497
- .slice()
498
- .map(row => {
499
- if (Array.isArray(row.children)) {
500
- return {
501
- ...row,
502
- children: doTheSort(row.children),
503
- }
504
- }
505
- return row
506
- })
507
- .sort((a: IndexedRow, b: IndexedRow) =>
508
- sortColumn?.sortType === 'ALPHA_NUM' ? alphaNumSort(a, b) : standardSort(a, b),
509
- )
510
- }
511
- return theRows.slice().sort(sortColumn?.sortType === 'ALPHA_NUM' ? alphaNumSort : standardSort)
512
- }
513
-
514
- const theSortedRows = doTheSort(rows)
515
-
516
- previousSortOrder = theSortedRows.map((row, index) => ({
517
- ...row,
518
- order: index,
519
- }))
520
- previousSortDirection = sortDirection
521
- previousSortColumn = sortColumn
522
- sortedRows = theSortedRows
523
- return
524
-
525
- function standardSort(a: R, b: R) {
526
- let aValue: Get<R, KeyPath> | string = getNestedProperty(a, sortProp, '')
527
- let bValue: Get<R, KeyPath> | string = getNestedProperty(b, sortProp, '')
528
-
529
- if (typeof aValue === 'string') {
530
- aValue = aValue.toUpperCase()
531
- }
532
- if (typeof bValue === 'string') {
533
- bValue = bValue.toUpperCase()
534
- }
535
-
536
- if (aValue < bValue) {
537
- return sortDirection === 'ASC' ? -1 : 1
538
- }
539
- if (aValue > bValue) {
540
- return sortDirection === 'ASC' ? 1 : -1
541
- }
542
-
543
- return 0
544
- }
545
-
546
- function alphaNumSort(a: R, b: R) {
547
- const aValue = getNestedProperty(a, sortProp, '')
548
- const bValue = getNestedProperty(b, sortProp, '')
549
- let compareResults = 0
550
- if (
551
- (typeof aValue === 'number' || typeof aValue === 'string') &&
552
- (typeof bValue === 'number' || typeof bValue === 'string')
553
- ) {
554
- compareResults = naturalCompare(aValue.toString(), bValue.toString())
555
- } else if (typeof aValue === 'number' || typeof aValue === 'string') {
556
- compareResults = 1
557
- } else if (typeof bValue === 'number' || typeof bValue === 'string') {
558
- compareResults = -1
559
- }
560
-
561
- return sortDirection === 'ASC' ? compareResults : compareResults * -1
562
- }
563
- }
564
-
565
- export function setColumnVisibility(columnProperties: Array<ColumnProperty> | ColumnProperty, visible: boolean) {
566
- if (!Array.isArray(columnProperties)) {
567
- columnProperties = [columnProperties]
568
- }
569
-
570
- try {
571
- untrack(() => {
572
- columnInfo.updateColumns(
573
- columnProperties.map((property): { property: ColumnProperty; visible: boolean } => ({
574
- property,
575
- visible,
576
- })),
577
- )
578
- })
579
- } catch (err) {
580
- console.error(`Error setting column visibility for columns ${JSON.stringify(columnProperties)}`, err)
581
- }
582
- }
583
-
584
- /** When called in `onMount` or an effect, will set the visibility of the specified columns whenever the value of `getVisible()` changes*/
585
- export function setColumnVisibilityWatch(
586
- columnProperties: Array<ColumnProperty> | ColumnProperty,
587
- getVisible: () => boolean,
588
- ) {
589
- $effect(() => {
590
- const visible = getVisible()
591
- setColumnVisibility(columnProperties, visible)
592
- })
593
- }
594
-
595
- function hideColumn(column: Column) {
596
- if (column) {
597
- columnInfo.updateColumn(column.property, { visible: false })
598
- }
599
- contextMenuColumn = undefined
600
- }
601
-
602
- function showColumn(column: Column) {
603
- if (column) {
604
- columnInfo.updateColumn(column.property, { visible: true })
605
- }
606
- contextMenuColumn = undefined
607
- }
608
-
609
- function contextMenuHandler(event: MouseEvent, column: string) {
610
- if ((columnHidingEnabled || columnResizingEnabled) && !event.ctrlKey) {
611
- event.preventDefault()
612
- contextMenuColumn = columnInfo.current[column]
613
- return contextMenu?.open(event, `col-${tableId}-${CSS.escape(column)}`)
614
- }
615
- return Promise.resolve()
616
- }
617
- // #endregion
618
- // #region Tree-specific functions
619
-
620
- // #endregion
621
-
622
- function onColumnMouseDown(event: MouseEvent, column: Column) {
623
- event.stopPropagation()
624
- if (!event.target || !(event.target instanceof HTMLDivElement)) {
625
- return
626
- }
627
-
628
- const htmlColumn = tableParent?.querySelector<HTMLTableCellElement>(
629
- `#col-${tableId}-${CSS.escape(column.property)}`,
630
- )
631
- const columnIndex = columns.findIndex(col => col.property === column.property)
632
- if (!htmlColumn || columnIndex === -1) {
633
- return
634
- }
635
-
636
- const mouseDownPageX = event.pageX
637
- const mouseDownColumnWidth = htmlColumn.offsetWidth
638
-
639
- event.target.classList.add('resizing')
640
- columnResizingIsActive = true
641
-
642
- const mouseMoveHandler = (mouseMoveEvent: MouseEvent) => {
643
- const widthDifference = mouseMoveEvent.pageX - mouseDownPageX
644
- const newWidth = `${mouseDownColumnWidth + widthDifference}px`
645
- htmlColumn.style.width = column.minWidth ? `max(${newWidth}, ${column.minWidth})` : newWidth
646
- }
647
-
648
- const mouseUpHandler = () => {
649
- if (!(event.target instanceof HTMLDivElement)) {
650
- return
651
- }
652
- event.target.classList.remove('resizing')
653
- columnInfo.updateColumn(column.property, { userWidth: htmlColumn.style.width })
654
-
655
- removeMouseMoveListener?.()
656
- removeMouseUpListener?.()
657
- columnResizeEnd?.({ column: columns[columnIndex] })
658
- // We want to ensure the user's mouseup event doesn't trigger a click event on the column header
659
- setTimeout(() => (columnResizingIsActive = false), 50)
660
- }
661
-
662
- const removeMouseMoveListener = tableParent ? on(tableParent, 'mousemove', mouseMoveHandler) : undefined
663
- const removeMouseUpListener = tableParent ? on(tableParent, 'mouseup', mouseUpHandler) : undefined
664
- }
665
-
666
- async function setDefaultColumnWidths() {
667
- useFixedLayout = false
668
- await tick()
669
- if (columnResizingEnabled) {
670
- columnInfo.updateColumns(
671
- columns.map((column): { property: ColumnProperty; userWidth: string } => {
672
- const thisColumnInfo = columnInfo.current[column.property]
673
- const prevUserWidth = thisColumnInfo?.userWidth
674
- // Minimum width will be inherited from the layout on reset, but this won't, so re-set it here if needed
675
- const explicitWidth = thisColumnInfo?.width
676
-
677
- return {
678
- property: column.property,
679
- userWidth: prevUserWidth ?? explicitWidth ?? getColumnThWidth(column.property),
680
- }
681
- }),
682
- )
683
- useFixedLayout = true
684
- }
685
- }
686
-
687
- function getColumnThWidth(property: string) {
688
- const thWidth =
689
- tableParent?.querySelector<HTMLTableCellElement>(`#col-${tableId}-${CSS.escape(property)}`)?.offsetWidth ?? 25
690
- return `${thWidth}px`
691
- }
692
- // #region Computed
693
- $effect(() => {
694
- selectedRowIdsStore.set(selectedRowIds)
695
- })
696
-
697
- $effect(() => {
698
- filteredRows = filterMethod(filter, rows, columns)
699
- // Should only run when filter/rows/columns changes since everything else is untracked
700
- untrack(() =>
701
- doSort({
702
- rows: filteredRows && Array.isArray(filteredRows) ? filteredRows : [],
703
- sortColumn: sortColumn,
704
- sortDirection: sortDirection,
705
- sameSortOrder: true,
706
- }),
707
- )
708
- })
709
-
710
- const responsiveClass = $derived(
711
- responsive === true ? 'table-responsive' : !!responsive ? `table-responsive-${responsive}` : '',
712
- )
713
- /** Includes information computed by the columnInfo store, including what we serialize to localstorage */
714
- const computedColumns = $derived(columnInfo.columns)
715
- const visibleColumns = $derived(computedColumns.filter(column => column.unhidable || column.visible))
716
- const hiddenColumns = $derived(computedColumns.filter(column => !column.unhidable && !column.visible))
717
- const showPagination = $derived((perPageCount && rows.length > perPageCount) || totalItemsCount > perPageCount)
718
-
719
- const footers = $derived(
720
- columns.map(column => {
721
- if (column.footer && showFooter) {
722
- const property = column.footer.altProperty ? column.footer.altProperty : column.property
723
- const mathFunctions = ['AVG', 'SUM']
724
-
725
- let columnValues: Array<number | GetRowProperty> = rows.map(row => {
726
- const value = isArbitraryProperty(property)
727
- ? ('' as Get<R, RowProperties<R>>)
728
- : getNestedProperty(row, property)
729
- if (
730
- column.footer?.fn &&
731
- typeof column.footer.fn === 'string' &&
732
- mathFunctions.includes(column.footer.fn) &&
733
- typeof value === 'string'
734
- ) {
735
- return parseFloat(value) || 0
736
- }
737
- return value
738
- })
739
-
740
- if (column.footer.requiredValue) {
741
- columnValues = columnValues.filter(value => value === column.footer?.requiredValue)
742
- }
743
-
744
- let value: FooterReducerValue<R>
745
-
746
- function sum(sum: number, val: unknown): number {
747
- if (typeof val === 'number') {
748
- return sum + val
749
- }
750
-
751
- if (typeof val === 'string') {
752
- const parsedVal = parseFloat(val)
753
- if (isNaN(parsedVal)) {
754
- return sum
755
- }
756
- return sum + parsedVal
757
- }
758
-
759
- return sum
760
- }
761
-
762
- switch (column.footer.fn) {
763
- case 'COUNT':
764
- value = columnValues.length
765
- break
766
- case 'AVG':
767
- value = (columnValues.reduce(sum, 0) / columnValues.length).toFixed(2)
768
- break
769
- case 'SUM':
770
- value = columnValues.reduce(sum, 0)
771
- break
772
- default:
773
- if (typeof column.footer.fn === 'function') {
774
- value = columnValues.reduce(column.footer.fn, column.footer.initialValue)
775
- } else {
776
- value = ''
777
- }
778
- break
779
- }
780
-
781
- return {
782
- property: column.property,
783
- value:
784
- column.footer.formatCurrency && (typeof value === 'string' || typeof value === 'number')
785
- ? formatCurrency(value, {})
786
- : value,
787
- }
788
- }
789
- return {
790
- property: column.property,
791
- value: '',
792
- }
793
- }),
794
- )
795
- //#endregion
796
- onMount(() => {
797
- setDefaultColumnWidths()
798
- })
799
- </script>
800
-
801
- {#if filterEnabled}
802
- <div class={headerRowClass}>
803
- <div class={headerColumnClass}>
804
- {@render header?.()}
805
- </div>
806
- <div class={filterColumnClass}>
807
- <Input
808
- label={filterLabel}
809
- showLabel={showFilterLabel}
810
- bind:value={filter}
811
- placeholder={filterPlaceholder}
812
- disabled={filterDisabled}
813
- readonly={filterReadonly || null}
814
- lazy={filterLazy}
815
- />
816
- <!-- || null is so svelte doesn't include readonly when it's falsy, probably a result of it being `restProps`'d on the input -->
817
- </div>
818
- </div>
819
- {/if}
820
-
821
- <!-- webkit-user-select: none; here fixes an issue in Safari where text selection of the column headers would happen while resizing the columns -->
822
- <div
823
- class={[parentClass, responsiveClass]}
824
- style={parentStyle}
825
- class:mb-3={showPagination}
826
- style:user-select={columnResizingIsActive ? 'none' : undefined}
827
- style:-webkit-user-select={columnResizingIsActive ? 'none' : undefined}
828
- style:cursor={columnResizingIsActive ? 'col-resize' : undefined}
829
- bind:this={tableParent}
830
- >
831
- <table
832
- id={tableId}
833
- class={['table mb-0', tableClass]}
834
- class:table-sm={size === 'sm'}
835
- class:table-layout-fixed={columnResizingEnabled && useFixedLayout}
836
- class:sticky={stickyHeader}
837
- class:table-striped={striped}
838
- class:table-bordered={bordered}
839
- class:table-hover={hover}
840
- {...rest}
841
- >
842
- <thead>
843
- <tr>
844
- {#each visibleColumns as column, index}
845
- {@const isSortColumn = sortColumn?.property === column.property}
846
- {@const doEllipsis = column.ellipsis || columnResizingEnabled}
847
-
848
- <th
849
- id="col-{tableId}-{column.property}"
850
- title={column.title ?? column.name}
851
- style:cursor={column.sortType !== false && !isArbitraryProperty(column.property) ? 'pointer' : ''}
852
- style:width={column.userWidth ?? column.width}
853
- style:min-width={column.minWidth}
854
- data-sort-specified={isSortColumn ? sortDirection : ''}
855
- class:text-nowrap={!column.wrap}
856
- class="unselectable {column.class ?? ''}"
857
- class:d-print-none={column.hideForPrint}
858
- class:text-left={!bs5 && column.align === 'left'}
859
- class:text-start={column.align === 'start' || (bs5 && column.align === 'left')}
860
- class:text-right={!bs5 && (column.align === 'right' || column.numeric)}
861
- class:text-end={column.align === 'end' || (bs5 && (column.align === 'right' || column.numeric))}
862
- class:text-center={column.align === 'center'}
863
- class:text-truncate={doEllipsis}
864
- class:bg-white={!bs5 && stickyHeader}
865
- onclick={() => columnClickHandler(column)}
866
- oncontextmenu={event => contextMenuHandler(event, column.property)}
867
- >
868
- <span
869
- class="text-truncate d-inline-block"
870
- style="vertical-align: bottom;"
871
- style:max-width={doEllipsis && isSortColumn ? 'calc(100% - 20px)' : '100%'}
872
- >
873
- <!-- Don't add any whitespace btwn these if blocks to keep the spaces correct -->
874
- {#if column.icon && column.iconLeft}
875
- <i class="fa-fw {column.iconPrefix || 'fas'} fa-{column.icon}"></i>{#if column.name}&nbsp;{/if}
876
- {/if}{column.name}{#if column.icon && !column.iconLeft}
877
- {#if column.name}&nbsp;{/if}<i class="fa-fw {column.iconPrefix || 'fas'} fa-{column.icon}"></i>
878
- {/if}
879
- </span>
880
- {#if isSortColumn}
881
- {sortDirection === 'ASC' ? '▲' : '▼'}
882
- {/if}
883
- {#if columnResizingEnabled && visibleColumns.length - 1 !== index}
884
- <!-- I think setting the role and aria-orientation attributes is the best we can do here -->
885
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
886
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
887
- <!-- svelte-ignore a11y_click_events_have_key_events -->
888
- <div
889
- id="col-resizer-{tableId}-{column.property}"
890
- tabindex={0}
891
- role="separator"
892
- aria-orientation="horizontal"
893
- class="resizer"
894
- style="height: 100%;"
895
- onmousedown={e => onColumnMouseDown(e, column)}
896
- >
897
- &nbsp;
898
- </div>
899
- {/if}
900
- </th>
901
- {/each}
902
- </tr>
903
- </thead>
904
- <tbody class:unselectable={selectionMode === 'RANGE'}>
905
- {#snippet tableRow(row: R & { originalIndex: number; uuid: string }, index: number)}
906
- {#if children}{@render children({ row, index })}{:else}
907
- <tr
908
- class:table-primary={rowIsSelected(row, rowSelectionIdProp, selectedRowIds)}
909
- onclick={() => rowClick(row)}
910
- >
911
- {#each computedColumns as column (column.property)}
912
- <Td
913
- align={column.align}
914
- property={column.property}
915
- >
916
- {isArbitraryProperty(column.property)
917
- ? ''
918
- : column.formatter
919
- ? column.formatter(getNestedProperty(row, column.property, ''))
920
- : getNestedProperty(row, column.property, '')}
921
- </Td>
922
- {/each}
923
- </tr>
924
- {/if}
925
- {/snippet}
926
- {#if body}
927
- {@render body?.({ rows: currentPageRows, visibleColumnsCount: visibleColumns.length })}
928
- {:else if !currentPageRows.length}
929
- {@render noRows?.({ visibleColumnsCount: visibleColumns.length })}
930
- <!--
931
- Only key the #each if the idProp is in the original row.
932
- Otherwise, the table will re-render all rows if idProp is 'uuid' and your original row doesn't have one
933
- -->
934
- {:else if idProp in rows[currentPageRows[0]?.originalIndex]}
935
- {#each currentPageRows as row, index (row[idProp])}
936
- {@render tableRow(row, index)}
937
- {/each}
938
- {:else}
939
- {#each currentPageRows as row, index}
940
- {@render tableRow(row, index)}
941
- {/each}
942
- {/if}
943
- </tbody>
944
- {#if showFooter && footers}
945
- <tfoot>
946
- <tr class="table-secondary">
947
- {#if footerRow}
948
- {@render footerRow({ footers })}
949
- {:else}
950
- {#each footers as { property, value } (property)}
951
- <Td
952
- {property}
953
- tagName="TH">{value ?? ''}</Td
954
- >
955
- {/each}
956
- {/if}
957
- </tr>
958
- </tfoot>
959
- {/if}
960
- </table>
961
- </div>
962
-
963
- <Pagination
964
- {perPageCount}
965
- {totalItemsCount}
966
- items={sortedRows}
967
- bind:currentPageItems={() => untrack(() => currentPageRows), items => (currentPageRows = items)}
968
- bind:currentPageNumber
969
- bind:lastPageNumber
970
- {pageChange}
971
- bind:this={paginationComponent}
972
- />
973
-
974
- <ContextMenu
975
- id="hide-column-context-menu"
976
- bind:this={contextMenu}
977
- >
978
- <button
979
- class={['dropdown-item', hideButtonClass]}
980
- class:disabled={contextMenuColumn?.unhidable}
981
- onclick={() => contextMenuColumn && hideColumn(contextMenuColumn)}
982
- >
983
- <i class="fas fa-eye-slash"></i>
984
- {#if contextMenuColumn?.name}
985
- {translate('common:tableHideColumn', 'Hide {{- columnName}}', { columnName: contextMenuColumn.name })}
986
- {:else if contextMenuColumn?.icon}
987
- {translate('common:hide', 'Hide')}
988
- <i class="{contextMenuColumn?.iconPrefix || 'fas'} fa-{contextMenuColumn?.icon}"></i>
989
- {/if}
990
- </button>
991
-
992
- {#if hiddenColumns.length > 0}
993
- <div class="dropdown-divider"></div>
994
- <h5 class="dropdown-header"><i class="fas fa-eye"></i> {translate('common:tableShowColumns', 'Show Column')}</h5>
995
- {/if}
996
- {#each hiddenColumns as hiddenColumn (hiddenColumn.property)}
997
- <button
998
- class="dropdown-item"
999
- onclick={() => showColumn(hiddenColumn)}
1000
- >
1001
- {#if hiddenColumn.name}
1002
- {hiddenColumn.name}
1003
- {:else if hiddenColumn.icon}
1004
- <i class="{hiddenColumn.iconPrefix || 'fas'} fa-{hiddenColumn.icon}"></i>
1005
- {/if}
1006
- </button>
1007
- {/each}
1008
- {#if columnResizingEnabled}
1009
- <div class="dropdown-divider"></div>
1010
- <DropdownItem
1011
- icon="broom"
1012
- onclick={() => {
1013
- columnInfo.updateColumns(
1014
- computedColumns.map(col => ({
1015
- property: col.property,
1016
- userWidth: undefined,
1017
- })),
1018
- )
1019
- setDefaultColumnWidths()
1020
- }}>{translate('common:tableResetColumnSizes', 'Reset Column Sizes')}</DropdownItem
1021
- >
1022
- {/if}
1023
- </ContextMenu>
1024
-
1025
- <style>
1026
- table.sticky {
1027
- position: relative;
1028
- }
1029
-
1030
- table.sticky th {
1031
- position: sticky;
1032
- top: 0;
1033
- box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.4);
1034
- }
1035
-
1036
- table.sticky > thead > tr > th {
1037
- z-index: 3;
1038
- }
1039
-
1040
- /* Remove bottom margins created by form-groups in the table (BS4 only) */
1041
- table > tbody :global(.form-group) {
1042
- margin-bottom: 0%;
1043
- }
1044
-
1045
- /* BS5 removed .form-group, which makes it a little trickier to target the label's parent div, but this should do it
1046
- - The Label component adds mb-3 to the parent div if the label is hidden or mb-1 if it's shown (unlikely if it's in a table)
1047
- - That div will have a .input-group, .form-control, or .form-select as a direct descendant
1048
- */
1049
- table
1050
- > tbody
1051
- :global(
1052
- td div:where(.mb-3, .mb-1):not(.form-group):has(> :where(.input-group, .form-control, .form-select, .btn-group))
1053
- ) {
1054
- margin-bottom: 0% !important;
1055
- }
1056
-
1057
- .resizer {
1058
- position: absolute;
1059
- top: 0;
1060
- right: 0;
1061
- width: 10px;
1062
- cursor: col-resize;
1063
- user-select: none;
1064
- }
1065
- .resizer:hover,
1066
- .resizing {
1067
- border-right: 5px solid darkslategray;
1068
- }
1069
- .table-layout-fixed {
1070
- table-layout: fixed;
1071
- }
1072
- </style>
1
+ <script module>
2
+ import { getBootstrapCdnVersion } from '@isoftdata/utility-bootstrap'
3
+ let bs5 = $state(false)
4
+ try {
5
+ bs5 = getBootstrapCdnVersion() === 5
6
+ } catch (e) {
7
+ onMount(() => {
8
+ bs5 = getBootstrapCdnVersion() === 5
9
+ })
10
+ }
11
+ </script>
12
+
13
+ <script
14
+ lang="ts"
15
+ generics="R extends UuidRowProps"
16
+ >
17
+ import type { Snippet } from 'svelte'
18
+
19
+ import { ColumnInfoRunicStore } from './column-info-store.svelte'
20
+
21
+ import type { i18n } from 'i18next'
22
+ import type { Get } from 'type-fest'
23
+ import {
24
+ type Column as GenericColumn,
25
+ type SortDirection,
26
+ type UuidRowProps,
27
+ type RowProperties,
28
+ type FooterReducerValue,
29
+ ARBITRARY_PROPERTY_PREFIX,
30
+ type ArbitraryProperty,
31
+ } from './'
32
+
33
+ import { writable, type Writable } from 'svelte/store'
34
+
35
+ import Pagination from './Pagination.svelte'
36
+ import Td from './Td.svelte'
37
+ import Input from '@isoftdata/svelte-input'
38
+ import ContextMenu, { DropdownItem } from '@isoftdata/svelte-context-menu'
39
+ import { v4 as uuid } from '@lukeed/uuid'
40
+ import { format as formatCurrency } from '@isoftdata/utility-currency'
41
+ import { setContext, tick, onMount, getContext, untrack } from 'svelte'
42
+ import { on } from 'svelte/events'
43
+ import naturalCompare from 'natural-compare-lite'
44
+ import type { HTMLTableAttributes, ClassValue } from 'svelte/elements'
45
+ import { translate as defaultTranslate } from '@isoftdata/utility-string'
46
+
47
+ const { t: translate } = getContext<i18n>('i18next') || { t: defaultTranslate }
48
+
49
+ type K = keyof R
50
+ type KeyPath = RowProperties<R>
51
+ type ColumnProperty = KeyPath | ArbitraryProperty
52
+
53
+ type IndexedRow = R & { originalIndex: number; uuid: string }
54
+ type GetRowProperty = Get<R, KeyPath>
55
+ type GetIndexedRowProperty = Get<IndexedRow, RowProperties<IndexedRow>>
56
+ type FooterValue = {
57
+ property: ColumnProperty
58
+ value: FooterReducerValue<R>
59
+ }
60
+ // Type hack so passing an array of columns without the type argument doesn't reduce the type of R to any
61
+ type Column = GenericColumn<R & {}>
62
+
63
+ interface Props extends Omit<HTMLTableAttributes, 'children' | `aria-${string}` | `bind:${string}` | `on:${string}`> {
64
+ filterEnabled?: boolean
65
+ columns: Array<Column>
66
+ tableId?: string
67
+ rows: Array<R>
68
+ sortDirection?: SortDirection
69
+ sortColumn?: Column | undefined
70
+ class?: ClassValue
71
+ perPageCount?: number
72
+ currentPageNumber?: number
73
+ showFooter?: boolean
74
+ footers?: Array<FooterValue>
75
+ lazySort?: boolean //when true, the table can only be resorted when the user clicks on a column header(not real time as data changes)
76
+ idProp?: K | 'uuid' //required for lazy sorting.
77
+ previousSortOrder?: Array<IndexedRow & { order: number }>
78
+ filterProps?: Array<KeyPath> | false
79
+ filter?: string
80
+ filterLabel?: string
81
+ filterPlaceholder?: string
82
+ filterDisabled?: boolean
83
+ filterReadonly?: boolean
84
+ filterLazy?: boolean | number
85
+ showFilterLabel?: boolean
86
+ headerRowClass?: ClassValue
87
+ headerColumnClass?: ClassValue
88
+ filterColumnClass?: ClassValue
89
+ columnHidingEnabled?: boolean
90
+ selectionEnabled?: boolean
91
+ // Array<number> | Array<string> | Array<R[K]> ???
92
+ selectedRowIds?: Array<GetIndexedRowProperty>
93
+ rowSelectionIdProp?: KeyPath | 'uuid'
94
+ rowSelectionRequiresModKey?: boolean
95
+ selectionMode?: 'SINGLE' | 'RANGE' | null
96
+ multiSelectEnabled?: boolean
97
+ lastClickedIdForRange?: GetIndexedRowProperty | undefined
98
+ totalItemsCount?: number
99
+ stickyHeader?: boolean
100
+ responsive?: boolean | 'sm' | 'md' | 'lg' | 'xl'
101
+ size?: 'sm' | ''
102
+ striped?: boolean
103
+ /** When true, enables tree-specific features. Required if you want to use `TreeRow`s. */
104
+ tree?: boolean
105
+ bordered?: boolean
106
+ hover?: boolean
107
+ previousSortColumn?: Column | undefined
108
+ previousSortDirection?: SortDirection | undefined
109
+ parentClass?: ClassValue
110
+ parentStyle?: string
111
+ hideButtonClass?: ClassValue
112
+ columnResizingEnabled?: boolean
113
+ /** If specified, where to serialize the columnInfo store */
114
+ localStorageKey?: string | undefined
115
+ columnClickedMethod?: typeof defaultColumnClicked
116
+ filterMethod?: typeof defaultFilter
117
+ rowMatchesFilterMethod?: typeof defaultRowMatchesFilter
118
+ filteredRows?: Array<IndexedRow>
119
+ sortedRows?: Array<IndexedRow>
120
+ currentPageRows?: Array<IndexedRow>
121
+ // snippets
122
+ header?: Snippet
123
+ body?: Snippet<[{ rows: Array<R & { originalIndex: number; uuid: string }>; visibleColumnsCount: number }]>
124
+ children?: Snippet<[{ row: R & { originalIndex: number; uuid: string }; index: number }]>
125
+ noRows?: Snippet<[{ visibleColumnsCount: number }]>
126
+ footerRow?: Snippet<[{ footers: Array<FooterValue> }]>
127
+ // callbacks
128
+ pageChange?: (context: { pageNumber: number }) => void
129
+ columnResizeEnd?: (context: { column: Column }) => void
130
+ }
131
+
132
+ const uid = $props.id()
133
+
134
+ let {
135
+ filterEnabled = false,
136
+ columns,
137
+ tableId = uid,
138
+ rows,
139
+ sortDirection = $bindable('ASC'),
140
+ sortColumn = $bindable(undefined),
141
+ class: tableClass = '',
142
+ perPageCount = 0,
143
+ currentPageNumber = $bindable(1),
144
+ showFooter = false,
145
+ lazySort = false,
146
+ idProp = 'uuid',
147
+ previousSortOrder = $bindable([]),
148
+ filterProps = false,
149
+ filter = $bindable(''),
150
+ filterLabel = translate('common:filter', 'Filter'),
151
+ filterPlaceholder = translate('common:filter', 'Filter'),
152
+ filterDisabled = false,
153
+ filterReadonly = false,
154
+ filterLazy = false,
155
+ showFilterLabel = false,
156
+ headerRowClass = bs5 ? 'row' : 'form-row',
157
+ headerColumnClass = 'col',
158
+ filterColumnClass = 'col-lg-2 col-md-4 col-sm-6 align-self-end',
159
+ columnHidingEnabled = false,
160
+ selectionEnabled = false,
161
+ selectedRowIds = $bindable([]),
162
+ rowSelectionIdProp = 'uuid',
163
+ rowSelectionRequiresModKey = false,
164
+ selectionMode = $bindable(null),
165
+ multiSelectEnabled = true,
166
+ lastClickedIdForRange = $bindable(undefined),
167
+ totalItemsCount = 0,
168
+ stickyHeader = false,
169
+ responsive = false,
170
+ size = 'sm',
171
+ striped = true,
172
+ tree = false,
173
+ bordered = true,
174
+ hover = true,
175
+ previousSortColumn = $bindable(undefined),
176
+ previousSortDirection = $bindable(undefined),
177
+ parentClass = '',
178
+ parentStyle = '',
179
+ hideButtonClass = '',
180
+ columnResizingEnabled = false,
181
+ localStorageKey = undefined,
182
+ columnClickedMethod = defaultColumnClicked,
183
+ filterMethod = defaultFilter,
184
+ rowMatchesFilterMethod = defaultRowMatchesFilter,
185
+ filteredRows = $bindable(filterMethod('', rows, columns)),
186
+ sortedRows = $bindable(filteredRows),
187
+ currentPageRows = $bindable(sortedRows.slice(0, perPageCount)),
188
+ // snippets
189
+ header,
190
+ body,
191
+ children,
192
+ noRows,
193
+ footerRow,
194
+ // callbacks
195
+ pageChange,
196
+ columnResizeEnd,
197
+ ...rest
198
+ }: Props = $props()
199
+
200
+ let lastPageNumber: number = $state(1)
201
+ let paginationComponent: Pagination<IndexedRow> | undefined = $state()
202
+ let contextMenu: ContextMenu | undefined = $state()
203
+ let contextMenuColumn: Column | undefined = $state()
204
+ let columnResizingIsActive: boolean = $state(false)
205
+ /** Set to true after getting initial table widths when resizing enabled*/
206
+ let useFixedLayout = $state(false)
207
+ let tableParent: HTMLDivElement | undefined = $state()
208
+
209
+ // Do the initial sort if they specified a default sort column
210
+ const defaultSortColumn = columns.find(column => column.defaultSortColumn)
211
+ if (defaultSortColumn) {
212
+ sortColumn = defaultSortColumn
213
+ sortDirection = defaultSortColumn.defaultSortDirection || 'ASC'
214
+
215
+ doSort({
216
+ rows: filteredRows,
217
+ sortColumn,
218
+ sortDirection,
219
+ sameSortOrder: false,
220
+ })
221
+ }
222
+
223
+ // Need to track which rows are expanded so when the rows reorder, they correctly keep that state.
224
+ const expandedRows = writable<Record<number | string, boolean>>({})
225
+ if (tree) {
226
+ setContext('expandedRows', expandedRows)
227
+ }
228
+
229
+ // TODO: bad things will happen if we update any of our apps to use Runes for the store if we don't account for that here
230
+ const session = getContext<Writable<{ userAccountId?: number }> | undefined>('session')
231
+ // This will be accessible on the component instance, ie `const columnInfo = $derived(table?.columnInfo)` if you need it
232
+ export const columnInfo = new ColumnInfoRunicStore<R>(() => columns, {
233
+ key: localStorageKey,
234
+ userAccountId: session && $session ? $session.userAccountId : undefined,
235
+ })
236
+ // Set as context so we can retrieve it in the Td component
237
+ setContext('columnInfo', columnInfo)
238
+ // Set as context so we can retrieve it in the TreeRow component
239
+ const selectedRowIdsStore = writable(selectedRowIds)
240
+ setContext('selectedRowIds', selectedRowIdsStore)
241
+ setContext('idProp', idProp)
242
+ setContext('columnResizingEnabled', writable(columnResizingEnabled))
243
+ setContext('bs5', bs5)
244
+
245
+ // #endregion
246
+ // #region Functions
247
+
248
+ function isArbitraryProperty(property: string | undefined): property is ArbitraryProperty {
249
+ return !!property?.startsWith(ARBITRARY_PROPERTY_PREFIX)
250
+ }
251
+
252
+ export async function defaultColumnClicked(clickedColumn: Column, sortDirection: SortDirection) {
253
+ if (currentPageNumber !== lastPageNumber) {
254
+ paginationComponent?.setPageNumber(1)
255
+ }
256
+
257
+ doSort({
258
+ rows: filteredRows,
259
+ sortColumn: clickedColumn,
260
+ sortDirection,
261
+ sameSortOrder: false,
262
+ })
263
+ }
264
+
265
+ function defaultFilter(filter: string, rows: R[], columns: Column[]): IndexedRow[] {
266
+ const columnProps = columns
267
+ ?.map(({ property }) => property)
268
+ .filter((property): property is KeyPath => !isArbitraryProperty(property))
269
+
270
+ if (filterEnabled) {
271
+ const props = filterProps || columnProps
272
+ return getFilterMatches(filter, rows, props)
273
+ }
274
+
275
+ return rows.map((row, index): IndexedRow => ({ ...row, originalIndex: index, uuid: row.uuid || uuid() }))
276
+ }
277
+
278
+ /** In normal mode, returns all rows matching the filter.
279
+ *
280
+ * In tree mode, shows all children if parent matches, or shows matching children and their children
281
+ */
282
+ function getFilterMatches(filter: string, theRows: Array<R>, props: KeyPath[]) {
283
+ return theRows.reduce((rows, row, index): IndexedRow[] => {
284
+ if (rowMatchesFilterMethod(filter, row, props)) {
285
+ rows.push({ ...row, originalIndex: index, uuid: 'uuid' in row ? (row.uuid as string) : uuid() })
286
+ } else if (tree && Array.isArray(row.children) && row.children.length) {
287
+ const children = getFilterMatches(filter, row.children, props)
288
+ if (children.length) {
289
+ rows.push({ ...row, children, originalIndex: index, uuid: 'uuid' in row ? (row.uuid as string) : uuid() })
290
+ }
291
+ }
292
+
293
+ return rows
294
+ }, new Array<IndexedRow>())
295
+ }
296
+
297
+ export function defaultRowMatchesFilter(filter: string, row: R, props: Array<RowProperties<R>>) {
298
+ return (
299
+ !filter ||
300
+ props.some(prop => {
301
+ const value = getNestedProperty(row, prop)
302
+ if (typeof value === 'string' || typeof value === 'number') {
303
+ return value.toString().toUpperCase().indexOf(filter.toUpperCase()) > -1
304
+ }
305
+ return false
306
+ })
307
+ )
308
+ }
309
+
310
+ export function getNestedProperty(
311
+ row: R & { uuid: string },
312
+ path: KeyPath | 'uuid',
313
+ defaultValue?: string | number,
314
+ ): GetIndexedRowProperty
315
+ export function getNestedProperty(row: R, path: KeyPath, defaultValue?: string | number): GetRowProperty
316
+ export function getNestedProperty(
317
+ row: R | (R & { uuid: string }),
318
+ path: KeyPath | 'uuid',
319
+ defaultValue?: string | number,
320
+ ): GetRowProperty | GetIndexedRowProperty {
321
+ if (!path) {
322
+ return defaultValue as GetRowProperty
323
+ }
324
+
325
+ if (row[path]) {
326
+ return row[path] as GetRowProperty
327
+ }
328
+
329
+ const val =
330
+ path
331
+ .split('[')
332
+ // TODO figure out if we can do this less evilly
333
+ .reduce((obj, prop) => (obj as any)?.[prop.replace(/\]/g, '')], row as any) ?? defaultValue
334
+ return val
335
+ }
336
+
337
+ async function columnClickHandler(clickedColumn: Column) {
338
+ if (clickedColumn.sortType === false || columnResizingIsActive || isArbitraryProperty(clickedColumn.property)) {
339
+ return
340
+ }
341
+ sortDirection = sortDirection === 'ASC' && clickedColumn.property === sortColumn?.property ? 'DESC' : 'ASC'
342
+
343
+ columnClickedMethod(clickedColumn, sortDirection)
344
+
345
+ sortColumn = clickedColumn
346
+ await tick()
347
+ }
348
+
349
+ export function expandRow(rowId: string | number, expanded = true) {
350
+ if (tree) {
351
+ $expandedRows[rowId] = expanded
352
+ } else {
353
+ console.warn('expandRow should only be used when the `tree` prop is set to true on the Table component.')
354
+ }
355
+ }
356
+
357
+ export function rowIsSelected(
358
+ row: IndexedRow,
359
+ rowSelectionIdProp: KeyPath | 'uuid',
360
+ selectedRowIds: Array<GetIndexedRowProperty>,
361
+ ) {
362
+ const selectionId = getNestedProperty(row, rowSelectionIdProp, row.uuid)
363
+ return selectedRowIds.includes(selectionId)
364
+ }
365
+
366
+ export function rowClick(row: IndexedRow) {
367
+ if (!selectionEnabled || !rowSelectionIdProp) {
368
+ return
369
+ }
370
+ const clickedId = getNestedProperty(row, rowSelectionIdProp || 'uuid')
371
+
372
+ const selectedIds = selectedRowIds
373
+
374
+ let newSelectedIds: Array<GetIndexedRowProperty> = []
375
+
376
+ if (!selectionMode && !rowSelectionRequiresModKey) {
377
+ selectionMode = 'SINGLE'
378
+ }
379
+
380
+ if (selectionMode === 'SINGLE') {
381
+ newSelectedIds = selectedIds
382
+ const foundInExistingSelections = selectedIds.some(id => id === clickedId)
383
+
384
+ if (foundInExistingSelections) {
385
+ newSelectedIds = newSelectedIds.filter(id => id !== clickedId)
386
+ } else if (multiSelectEnabled) {
387
+ newSelectedIds = newSelectedIds.concat(clickedId)
388
+ } else {
389
+ newSelectedIds = [clickedId]
390
+ }
391
+ } else if (selectionMode === 'RANGE') {
392
+ if (selectedIds.length < 1) {
393
+ newSelectedIds = [clickedId]
394
+ } else {
395
+ newSelectedIds = selectedIds
396
+ const clickedItemIndex = sortedRows.findIndex(item => {
397
+ return item[rowSelectionIdProp] === clickedId
398
+ })
399
+
400
+ const lastSelectedIndex = sortedRows.findIndex(item => {
401
+ return item[rowSelectionIdProp] === lastClickedIdForRange
402
+ })
403
+
404
+ let selectionDirection: 'UP' | 'DOWN' | undefined
405
+
406
+ if (lastSelectedIndex > clickedItemIndex) {
407
+ selectionDirection = 'UP'
408
+ } else if (lastSelectedIndex < clickedItemIndex) {
409
+ selectionDirection = 'DOWN'
410
+ }
411
+
412
+ if (selectionDirection) {
413
+ sortedRows.forEach((item, index) => {
414
+ if (selectionDirection === 'UP' && index <= lastSelectedIndex && index >= clickedItemIndex) {
415
+ newSelectedIds = newSelectedIds.concat(getNestedProperty(item, rowSelectionIdProp))
416
+ } else if (selectionDirection === 'DOWN' && index >= lastSelectedIndex && index <= clickedItemIndex) {
417
+ newSelectedIds = newSelectedIds.concat(getNestedProperty(item, rowSelectionIdProp))
418
+ }
419
+ })
420
+ }
421
+ }
422
+ } else {
423
+ return
424
+ }
425
+
426
+ const clickedIdWasAdded = newSelectedIds.find(id => id === clickedId)
427
+
428
+ let lastClickedId: GetIndexedRowProperty | undefined
429
+
430
+ if (newSelectedIds.length === 0) {
431
+ lastClickedId = undefined
432
+ } else if (selectionMode === 'SINGLE' && clickedIdWasAdded) {
433
+ lastClickedId = clickedId
434
+ } else {
435
+ lastClickedId = lastClickedIdForRange
436
+ }
437
+
438
+ selectedRowIds = [...new Set(newSelectedIds)]
439
+ lastClickedIdForRange = lastClickedId
440
+ }
441
+ export function setPageVisibleByItemIndex(index: number) {
442
+ if (index > -1) {
443
+ paginationComponent?.setPageVisibleByItemIndex(index)
444
+ }
445
+ }
446
+ export function setPageVisibleByItemId({ id, keyName }: { id: number | string; keyName: K }) {
447
+ const index = sortedRows.findIndex(item => item[keyName] == id)
448
+
449
+ if (index > -1) {
450
+ paginationComponent?.setPageVisibleByItemIndex(index)
451
+ }
452
+ }
453
+ export function doSort({
454
+ rows,
455
+ sortColumn,
456
+ sortDirection,
457
+ sameSortOrder,
458
+ }: {
459
+ rows: Array<IndexedRow>
460
+ sortColumn: Column | undefined
461
+ sortDirection: SortDirection
462
+ sameSortOrder: boolean
463
+ }) {
464
+ const keepSameSortOrder = !!(lazySort && sameSortOrder && idProp)
465
+ const hasPreviousSort = previousSortColumn && previousSortOrder.length > 0
466
+
467
+ let sortProp: KeyPath
468
+ if (
469
+ keepSameSortOrder &&
470
+ hasPreviousSort &&
471
+ previousSortColumn &&
472
+ idProp &&
473
+ !isArbitraryProperty(previousSortColumn.property)
474
+ ) {
475
+ sortColumn = previousSortColumn
476
+ sortProp = previousSortColumn.property
477
+ sortDirection = 'ASC' //force ASC for keeping the same order because we defined the order in previousSortOrder
478
+
479
+ rows = rows.map(row => {
480
+ const foundItem = previousSortOrder.find(
481
+ previousSortOrderRow => !!idProp && previousSortOrderRow[idProp] === row[idProp],
482
+ )
483
+
484
+ return { ...row, order: foundItem ? foundItem.order : 0 }
485
+ })
486
+ } else if (sortColumn?.property && !isArbitraryProperty(sortColumn.property)) {
487
+ sortProp = sortColumn.property
488
+ } else {
489
+ // no sort property, can't sort
490
+ sortedRows = rows
491
+ return
492
+ }
493
+
494
+ function doTheSort(theRows: Array<IndexedRow>): Array<IndexedRow> {
495
+ if (tree) {
496
+ return theRows
497
+ .slice()
498
+ .map(row => {
499
+ if (Array.isArray(row.children)) {
500
+ return {
501
+ ...row,
502
+ children: doTheSort(row.children),
503
+ }
504
+ }
505
+ return row
506
+ })
507
+ .sort((a: IndexedRow, b: IndexedRow) =>
508
+ sortColumn?.sortType === 'ALPHA_NUM' ? alphaNumSort(a, b) : standardSort(a, b),
509
+ )
510
+ }
511
+ return theRows.slice().sort(sortColumn?.sortType === 'ALPHA_NUM' ? alphaNumSort : standardSort)
512
+ }
513
+
514
+ const theSortedRows = doTheSort(rows)
515
+
516
+ previousSortOrder = theSortedRows.map((row, index) => ({
517
+ ...row,
518
+ order: index,
519
+ }))
520
+ previousSortDirection = sortDirection
521
+ previousSortColumn = sortColumn
522
+ sortedRows = theSortedRows
523
+ return
524
+
525
+ function standardSort(a: R, b: R) {
526
+ let aValue: Get<R, KeyPath> | string = getNestedProperty(a, sortProp, '')
527
+ let bValue: Get<R, KeyPath> | string = getNestedProperty(b, sortProp, '')
528
+
529
+ if (typeof aValue === 'string') {
530
+ aValue = aValue.toUpperCase()
531
+ }
532
+ if (typeof bValue === 'string') {
533
+ bValue = bValue.toUpperCase()
534
+ }
535
+
536
+ if (aValue < bValue) {
537
+ return sortDirection === 'ASC' ? -1 : 1
538
+ }
539
+ if (aValue > bValue) {
540
+ return sortDirection === 'ASC' ? 1 : -1
541
+ }
542
+
543
+ return 0
544
+ }
545
+
546
+ function alphaNumSort(a: R, b: R) {
547
+ const aValue = getNestedProperty(a, sortProp, '')
548
+ const bValue = getNestedProperty(b, sortProp, '')
549
+ let compareResults = 0
550
+ if (
551
+ (typeof aValue === 'number' || typeof aValue === 'string') &&
552
+ (typeof bValue === 'number' || typeof bValue === 'string')
553
+ ) {
554
+ compareResults = naturalCompare(aValue.toString(), bValue.toString())
555
+ } else if (typeof aValue === 'number' || typeof aValue === 'string') {
556
+ compareResults = 1
557
+ } else if (typeof bValue === 'number' || typeof bValue === 'string') {
558
+ compareResults = -1
559
+ }
560
+
561
+ return sortDirection === 'ASC' ? compareResults : compareResults * -1
562
+ }
563
+ }
564
+
565
+ export function setColumnVisibility(columnProperties: Array<ColumnProperty> | ColumnProperty, visible: boolean) {
566
+ if (!Array.isArray(columnProperties)) {
567
+ columnProperties = [columnProperties]
568
+ }
569
+
570
+ try {
571
+ untrack(() => {
572
+ columnInfo.updateColumns(
573
+ columnProperties.map((property): { property: ColumnProperty; visible: boolean } => ({
574
+ property,
575
+ visible,
576
+ })),
577
+ )
578
+ })
579
+ } catch (err) {
580
+ console.error(`Error setting column visibility for columns ${JSON.stringify(columnProperties)}`, err)
581
+ }
582
+ }
583
+
584
+ /** When called in `onMount` or an effect, will set the visibility of the specified columns whenever the value of `getVisible()` changes*/
585
+ export function setColumnVisibilityWatch(
586
+ columnProperties: Array<ColumnProperty> | ColumnProperty,
587
+ getVisible: () => boolean,
588
+ ) {
589
+ $effect(() => {
590
+ const visible = getVisible()
591
+ setColumnVisibility(columnProperties, visible)
592
+ })
593
+ }
594
+
595
+ function hideColumn(column: Column) {
596
+ if (column) {
597
+ columnInfo.updateColumn(column.property, { visible: false })
598
+ }
599
+ contextMenuColumn = undefined
600
+ }
601
+
602
+ function showColumn(column: Column) {
603
+ if (column) {
604
+ columnInfo.updateColumn(column.property, { visible: true })
605
+ }
606
+ contextMenuColumn = undefined
607
+ }
608
+
609
+ function contextMenuHandler(event: MouseEvent, column: string) {
610
+ if ((columnHidingEnabled || columnResizingEnabled) && !event.ctrlKey) {
611
+ event.preventDefault()
612
+ contextMenuColumn = columnInfo.current[column]
613
+ return contextMenu?.open(event, `col-${tableId}-${CSS.escape(column)}`)
614
+ }
615
+ return Promise.resolve()
616
+ }
617
+ // #endregion
618
+ // #region Tree-specific functions
619
+
620
+ // #endregion
621
+
622
+ function onColumnMouseDown(event: MouseEvent, column: Column) {
623
+ event.stopPropagation()
624
+ if (!event.target || !(event.target instanceof HTMLDivElement)) {
625
+ return
626
+ }
627
+
628
+ const htmlColumn = tableParent?.querySelector<HTMLTableCellElement>(
629
+ `#col-${tableId}-${CSS.escape(column.property)}`,
630
+ )
631
+ const columnIndex = columns.findIndex(col => col.property === column.property)
632
+ if (!htmlColumn || columnIndex === -1) {
633
+ return
634
+ }
635
+
636
+ const mouseDownPageX = event.pageX
637
+ const mouseDownColumnWidth = htmlColumn.offsetWidth
638
+
639
+ event.target.classList.add('resizing')
640
+ columnResizingIsActive = true
641
+
642
+ const mouseMoveHandler = (mouseMoveEvent: MouseEvent) => {
643
+ const widthDifference = mouseMoveEvent.pageX - mouseDownPageX
644
+ const newWidth = `${mouseDownColumnWidth + widthDifference}px`
645
+ htmlColumn.style.width = column.minWidth ? `max(${newWidth}, ${column.minWidth})` : newWidth
646
+ }
647
+
648
+ const mouseUpHandler = () => {
649
+ if (!(event.target instanceof HTMLDivElement)) {
650
+ return
651
+ }
652
+ event.target.classList.remove('resizing')
653
+ columnInfo.updateColumn(column.property, { userWidth: htmlColumn.style.width })
654
+
655
+ removeMouseMoveListener?.()
656
+ removeMouseUpListener?.()
657
+ columnResizeEnd?.({ column: columns[columnIndex] })
658
+ // We want to ensure the user's mouseup event doesn't trigger a click event on the column header
659
+ setTimeout(() => (columnResizingIsActive = false), 50)
660
+ }
661
+
662
+ const removeMouseMoveListener = tableParent ? on(tableParent, 'mousemove', mouseMoveHandler) : undefined
663
+ const removeMouseUpListener = tableParent ? on(tableParent, 'mouseup', mouseUpHandler) : undefined
664
+ }
665
+
666
+ async function setDefaultColumnWidths() {
667
+ useFixedLayout = false
668
+ await tick()
669
+ if (columnResizingEnabled) {
670
+ columnInfo.updateColumns(
671
+ columns.map((column): { property: ColumnProperty; userWidth: string } => {
672
+ const thisColumnInfo = columnInfo.current[column.property]
673
+ const prevUserWidth = thisColumnInfo?.userWidth
674
+ // Minimum width will be inherited from the layout on reset, but this won't, so re-set it here if needed
675
+ const explicitWidth = thisColumnInfo?.width
676
+
677
+ return {
678
+ property: column.property,
679
+ userWidth: prevUserWidth ?? explicitWidth ?? getColumnThWidth(column.property),
680
+ }
681
+ }),
682
+ )
683
+ useFixedLayout = true
684
+ }
685
+ }
686
+
687
+ function getColumnThWidth(property: string) {
688
+ const thWidth =
689
+ tableParent?.querySelector<HTMLTableCellElement>(`#col-${tableId}-${CSS.escape(property)}`)?.offsetWidth ?? 25
690
+ return `${thWidth}px`
691
+ }
692
+ // #region Computed
693
+ $effect(() => {
694
+ selectedRowIdsStore.set(selectedRowIds)
695
+ })
696
+
697
+ $effect(() => {
698
+ filteredRows = filterMethod(filter, rows, columns)
699
+ // Should only run when filter/rows/columns changes since everything else is untracked
700
+ untrack(() =>
701
+ doSort({
702
+ rows: filteredRows && Array.isArray(filteredRows) ? filteredRows : [],
703
+ sortColumn: sortColumn,
704
+ sortDirection: sortDirection,
705
+ sameSortOrder: true,
706
+ }),
707
+ )
708
+ })
709
+
710
+ const responsiveClass = $derived(
711
+ responsive === true ? 'table-responsive' : !!responsive ? `table-responsive-${responsive}` : '',
712
+ )
713
+ /** Includes information computed by the columnInfo store, including what we serialize to localstorage */
714
+ const computedColumns = $derived(columnInfo.columns)
715
+ const visibleColumns = $derived(computedColumns.filter(column => column.unhidable || column.visible))
716
+ const hiddenColumns = $derived(computedColumns.filter(column => !column.unhidable && !column.visible))
717
+ const showPagination = $derived((perPageCount && rows.length > perPageCount) || totalItemsCount > perPageCount)
718
+
719
+ const footers = $derived(
720
+ columns.map(column => {
721
+ if (column.footer && showFooter) {
722
+ const property = column.footer.altProperty ? column.footer.altProperty : column.property
723
+ const mathFunctions = ['AVG', 'SUM']
724
+
725
+ let columnValues: Array<number | GetRowProperty> = rows.map(row => {
726
+ const value = isArbitraryProperty(property)
727
+ ? ('' as Get<R, RowProperties<R>>)
728
+ : getNestedProperty(row, property)
729
+ if (
730
+ column.footer?.fn &&
731
+ typeof column.footer.fn === 'string' &&
732
+ mathFunctions.includes(column.footer.fn) &&
733
+ typeof value === 'string'
734
+ ) {
735
+ return parseFloat(value) || 0
736
+ }
737
+ return value
738
+ })
739
+
740
+ if (column.footer.requiredValue) {
741
+ columnValues = columnValues.filter(value => value === column.footer?.requiredValue)
742
+ }
743
+
744
+ let value: FooterReducerValue<R>
745
+
746
+ function sum(sum: number, val: unknown): number {
747
+ if (typeof val === 'number') {
748
+ return sum + val
749
+ }
750
+
751
+ if (typeof val === 'string') {
752
+ const parsedVal = parseFloat(val)
753
+ if (isNaN(parsedVal)) {
754
+ return sum
755
+ }
756
+ return sum + parsedVal
757
+ }
758
+
759
+ return sum
760
+ }
761
+
762
+ switch (column.footer.fn) {
763
+ case 'COUNT':
764
+ value = columnValues.length
765
+ break
766
+ case 'AVG':
767
+ value = (columnValues.reduce(sum, 0) / columnValues.length).toFixed(2)
768
+ break
769
+ case 'SUM':
770
+ value = columnValues.reduce(sum, 0)
771
+ break
772
+ default:
773
+ if (typeof column.footer.fn === 'function') {
774
+ value = columnValues.reduce(column.footer.fn, column.footer.initialValue)
775
+ } else {
776
+ value = ''
777
+ }
778
+ break
779
+ }
780
+
781
+ return {
782
+ property: column.property,
783
+ value:
784
+ column.footer.formatCurrency && (typeof value === 'string' || typeof value === 'number')
785
+ ? formatCurrency(value)
786
+ : value,
787
+ }
788
+ }
789
+ return {
790
+ property: column.property,
791
+ value: '',
792
+ }
793
+ }),
794
+ )
795
+ //#endregion
796
+ onMount(() => {
797
+ setDefaultColumnWidths()
798
+ })
799
+ </script>
800
+
801
+ {#if filterEnabled}
802
+ <div class={headerRowClass}>
803
+ <div class={headerColumnClass}>
804
+ {@render header?.()}
805
+ </div>
806
+ <div class={filterColumnClass}>
807
+ <Input
808
+ label={filterLabel}
809
+ showLabel={showFilterLabel}
810
+ bind:value={filter}
811
+ placeholder={filterPlaceholder}
812
+ disabled={filterDisabled}
813
+ readonly={filterReadonly || null}
814
+ lazy={filterLazy}
815
+ />
816
+ <!-- || null is so svelte doesn't include readonly when it's falsy, probably a result of it being `restProps`'d on the input -->
817
+ </div>
818
+ </div>
819
+ {/if}
820
+
821
+ <!-- webkit-user-select: none; here fixes an issue in Safari where text selection of the column headers would happen while resizing the columns -->
822
+ <div
823
+ class={[parentClass, responsiveClass]}
824
+ style={parentStyle}
825
+ class:mb-3={showPagination}
826
+ style:user-select={columnResizingIsActive ? 'none' : undefined}
827
+ style:-webkit-user-select={columnResizingIsActive ? 'none' : undefined}
828
+ style:cursor={columnResizingIsActive ? 'col-resize' : undefined}
829
+ bind:this={tableParent}
830
+ >
831
+ <table
832
+ id={tableId}
833
+ class={['table mb-0', tableClass]}
834
+ class:table-sm={size === 'sm'}
835
+ class:table-layout-fixed={columnResizingEnabled && useFixedLayout}
836
+ class:sticky={stickyHeader}
837
+ class:table-striped={striped}
838
+ class:table-bordered={bordered}
839
+ class:table-hover={hover}
840
+ {...rest}
841
+ >
842
+ <thead>
843
+ <tr>
844
+ {#each visibleColumns as column, index}
845
+ {@const isSortColumn = sortColumn?.property === column.property}
846
+ {@const doEllipsis = column.ellipsis || columnResizingEnabled}
847
+
848
+ <th
849
+ id="col-{tableId}-{column.property}"
850
+ title={column.title ?? column.name}
851
+ style:cursor={column.sortType !== false && !isArbitraryProperty(column.property) ? 'pointer' : ''}
852
+ style:width={column.userWidth ?? column.width}
853
+ style:min-width={column.minWidth}
854
+ data-sort-specified={isSortColumn ? sortDirection : ''}
855
+ class:text-nowrap={!column.wrap}
856
+ class="unselectable {column.class ?? ''}"
857
+ class:d-print-none={column.hideForPrint}
858
+ class:text-left={!bs5 && column.align === 'left'}
859
+ class:text-start={column.align === 'start' || (bs5 && column.align === 'left')}
860
+ class:text-right={!bs5 && (column.align === 'right' || column.numeric)}
861
+ class:text-end={column.align === 'end' || (bs5 && (column.align === 'right' || column.numeric))}
862
+ class:text-center={column.align === 'center'}
863
+ class:text-truncate={doEllipsis}
864
+ class:bg-white={!bs5 && stickyHeader}
865
+ onclick={() => columnClickHandler(column)}
866
+ oncontextmenu={event => contextMenuHandler(event, column.property)}
867
+ >
868
+ <span
869
+ class="text-truncate d-inline-block"
870
+ style="vertical-align: bottom;"
871
+ style:max-width={doEllipsis && isSortColumn ? 'calc(100% - 20px)' : '100%'}
872
+ >
873
+ <!-- Don't add any whitespace btwn these if blocks to keep the spaces correct -->
874
+ {#if column.icon && column.iconLeft}
875
+ <i class="fa-fw {column.iconPrefix || 'fas'} fa-{column.icon}"></i>{#if column.name}&nbsp;{/if}
876
+ {/if}{column.name}{#if column.icon && !column.iconLeft}
877
+ {#if column.name}&nbsp;{/if}<i class="fa-fw {column.iconPrefix || 'fas'} fa-{column.icon}"></i>
878
+ {/if}
879
+ </span>
880
+ {#if isSortColumn}
881
+ {sortDirection === 'ASC' ? '▲' : '▼'}
882
+ {/if}
883
+ {#if columnResizingEnabled && visibleColumns.length - 1 !== index}
884
+ <!-- I think setting the role and aria-orientation attributes is the best we can do here -->
885
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
886
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
887
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
888
+ <div
889
+ id="col-resizer-{tableId}-{column.property}"
890
+ tabindex={0}
891
+ role="separator"
892
+ aria-orientation="horizontal"
893
+ class="resizer"
894
+ style="height: 100%;"
895
+ onmousedown={e => onColumnMouseDown(e, column)}
896
+ >
897
+ &nbsp;
898
+ </div>
899
+ {/if}
900
+ </th>
901
+ {/each}
902
+ </tr>
903
+ </thead>
904
+ <tbody class:unselectable={selectionMode === 'RANGE'}>
905
+ {#snippet tableRow(row: R & { originalIndex: number; uuid: string }, index: number)}
906
+ {#if children}
907
+ {@render children({ row, index })}
908
+ {:else}
909
+ <tr
910
+ class:table-primary={rowIsSelected(row, rowSelectionIdProp, selectedRowIds)}
911
+ onclick={() => rowClick(row)}
912
+ >
913
+ {#each computedColumns as column (column.property)}
914
+ <Td
915
+ align={column.align}
916
+ property={column.property}
917
+ >
918
+ {isArbitraryProperty(column.property)
919
+ ? ''
920
+ : column.formatter
921
+ ? column.formatter(getNestedProperty(row, column.property, ''))
922
+ : getNestedProperty(row, column.property, '')}
923
+ </Td>
924
+ {/each}
925
+ </tr>
926
+ {/if}
927
+ {/snippet}
928
+ {#if body}
929
+ {@render body?.({ rows: currentPageRows, visibleColumnsCount: visibleColumns.length })}
930
+ {:else if !currentPageRows.length}
931
+ {@render noRows?.({ visibleColumnsCount: visibleColumns.length })}
932
+ <!--
933
+ Only key the #each if the idProp is in the original row.
934
+ Otherwise, the table will re-render all rows if idProp is 'uuid' and your original row doesn't have one
935
+ -->
936
+ {:else if idProp in rows[currentPageRows[0]?.originalIndex]}
937
+ {#each currentPageRows as row, index (row[idProp])}
938
+ {@render tableRow(row, index)}
939
+ {/each}
940
+ {:else}
941
+ {#each currentPageRows as row, index}
942
+ {@render tableRow(row, index)}
943
+ {/each}
944
+ {/if}
945
+ </tbody>
946
+ {#if showFooter && footers}
947
+ <tfoot>
948
+ <tr class="table-secondary">
949
+ {#if footerRow}
950
+ {@render footerRow({ footers })}
951
+ {:else}
952
+ {#each footers as { property, value } (property)}
953
+ <Td
954
+ {property}
955
+ tagName="TH">{value ?? ''}</Td
956
+ >
957
+ {/each}
958
+ {/if}
959
+ </tr>
960
+ </tfoot>
961
+ {/if}
962
+ </table>
963
+ </div>
964
+
965
+ <Pagination
966
+ {perPageCount}
967
+ {totalItemsCount}
968
+ items={sortedRows}
969
+ bind:currentPageItems={() => untrack(() => currentPageRows), items => (currentPageRows = items)}
970
+ bind:currentPageNumber
971
+ bind:lastPageNumber
972
+ {pageChange}
973
+ bind:this={paginationComponent}
974
+ />
975
+
976
+ <ContextMenu
977
+ id="hide-column-context-menu"
978
+ bind:this={contextMenu}
979
+ >
980
+ <button
981
+ class={['dropdown-item', hideButtonClass]}
982
+ class:disabled={contextMenuColumn?.unhidable}
983
+ onclick={() => contextMenuColumn && hideColumn(contextMenuColumn)}
984
+ >
985
+ <i class="fas fa-eye-slash"></i>
986
+ {#if contextMenuColumn?.name}
987
+ {translate('common:tableHideColumn', 'Hide {{- columnName}}', { columnName: contextMenuColumn.name })}
988
+ {:else if contextMenuColumn?.icon}
989
+ {translate('common:hide', 'Hide')}
990
+ <i class="{contextMenuColumn?.iconPrefix || 'fas'} fa-{contextMenuColumn?.icon}"></i>
991
+ {/if}
992
+ </button>
993
+
994
+ {#if hiddenColumns.length > 0}
995
+ <div class="dropdown-divider"></div>
996
+ <h5 class="dropdown-header"><i class="fas fa-eye"></i> {translate('common:tableShowColumns', 'Show Column')}</h5>
997
+ {/if}
998
+ {#each hiddenColumns as hiddenColumn (hiddenColumn.property)}
999
+ <button
1000
+ class="dropdown-item"
1001
+ onclick={() => showColumn(hiddenColumn)}
1002
+ >
1003
+ {#if hiddenColumn.name}
1004
+ {hiddenColumn.name}
1005
+ {:else if hiddenColumn.icon}
1006
+ <i class="{hiddenColumn.iconPrefix || 'fas'} fa-{hiddenColumn.icon}"></i>
1007
+ {/if}
1008
+ </button>
1009
+ {/each}
1010
+ {#if columnResizingEnabled}
1011
+ <div class="dropdown-divider"></div>
1012
+ <DropdownItem
1013
+ icon="broom"
1014
+ onclick={() => {
1015
+ columnInfo.updateColumns(
1016
+ computedColumns.map(col => ({
1017
+ property: col.property,
1018
+ userWidth: undefined,
1019
+ })),
1020
+ )
1021
+ setDefaultColumnWidths()
1022
+ }}>{translate('common:tableResetColumnSizes', 'Reset Column Sizes')}</DropdownItem
1023
+ >
1024
+ {/if}
1025
+ </ContextMenu>
1026
+
1027
+ <style>
1028
+ table.sticky {
1029
+ position: relative;
1030
+ }
1031
+
1032
+ table.sticky th {
1033
+ position: sticky;
1034
+ top: 0;
1035
+ box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.4);
1036
+ }
1037
+
1038
+ table.sticky > thead > tr > th {
1039
+ z-index: 3;
1040
+ }
1041
+
1042
+ /* Remove bottom margins created by form-groups in the table (BS4 only) */
1043
+ table > tbody :global(.form-group) {
1044
+ margin-bottom: 0%;
1045
+ }
1046
+
1047
+ /* BS5 removed .form-group, which makes it a little trickier to target the label's parent div, but this should do it
1048
+ - The Label component adds mb-3 to the parent div if the label is hidden or mb-1 if it's shown (unlikely if it's in a table)
1049
+ - That div will have a .input-group, .form-control, or .form-select as a direct descendant
1050
+ */
1051
+ table
1052
+ > tbody
1053
+ :global(
1054
+ td div:where(.mb-3, .mb-1):not(.form-group):has(> :where(.input-group, .form-control, .form-select, .btn-group))
1055
+ ) {
1056
+ margin-bottom: 0% !important;
1057
+ }
1058
+
1059
+ .resizer {
1060
+ position: absolute;
1061
+ top: 0;
1062
+ right: 0;
1063
+ width: 10px;
1064
+ cursor: col-resize;
1065
+ user-select: none;
1066
+ }
1067
+ .resizer:hover,
1068
+ .resizing {
1069
+ border-right: 5px solid darkslategray;
1070
+ }
1071
+ .table-layout-fixed {
1072
+ table-layout: fixed;
1073
+ }
1074
+ </style>