@isoftdata/svelte-table 2.11.1 → 2.12.0

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