@isoftdata/svelte-table 2.6.5 → 2.6.6

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