runeforge 0.0.32 → 0.0.35
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.
|
@@ -1,160 +1,168 @@
|
|
|
1
1
|
<script lang="ts" generics="T extends object">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import Label from '../form/Label.svelte';
|
|
4
|
+
import Button from '../form/Button.svelte';
|
|
5
|
+
import { getIconSet } from '../../icons/context.js';
|
|
6
|
+
import { defaultIconSet } from '../../icons/sets/default.js';
|
|
7
|
+
import { getStrings } from '../../i18n/context.js';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
const strings = getStrings();
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
onMount(() => {
|
|
12
|
+
import('cally');
|
|
13
|
+
});
|
|
14
|
+
import type { DistinctEntry } from '../../types/table.js';
|
|
15
|
+
import type { FilterState } from './state.svelte.js';
|
|
16
|
+
import type { ColumnDefinition } from '../../types/crud.js';
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
18
|
+
let {
|
|
19
|
+
column,
|
|
20
|
+
entries = [],
|
|
21
|
+
filter,
|
|
22
|
+
maxCheckboxValues = 20,
|
|
23
|
+
onchange
|
|
24
|
+
}: {
|
|
25
|
+
column: ColumnDefinition<T>;
|
|
26
|
+
entries?: DistinctEntry<T>[];
|
|
27
|
+
filter: FilterState;
|
|
28
|
+
maxCheckboxValues?: number;
|
|
29
|
+
onchange?: () => void;
|
|
30
|
+
} = $props();
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
const icons = $derived(getIconSet() ?? defaultIconSet);
|
|
33
|
+
const popId = $derived(`filter-pop-${column.attribute}`);
|
|
34
|
+
const anchor = $derived(`--filter-anchor-${column.attribute}`);
|
|
35
|
+
const isDatetime = $derived(column.type === 'datetime');
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
const dateRange = $derived(filter.dateRangeFor(column.attribute));
|
|
38
|
+
const calendarValue = $derived(
|
|
39
|
+
dateRange.from || dateRange.to ? `${dateRange.from}/${dateRange.to}` : ''
|
|
40
|
+
);
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
let calendarEl: HTMLElement | undefined = $state();
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
44
|
+
$effect(() => {
|
|
45
|
+
if (!calendarEl) return;
|
|
46
|
+
function handler() {
|
|
47
|
+
const value = (calendarEl as HTMLElement & { value: string }).value ?? '';
|
|
48
|
+
const [from = '', to = ''] = value.split('/');
|
|
49
|
+
filter.setDateRange(column.attribute, from, to);
|
|
50
|
+
onchange?.();
|
|
51
|
+
}
|
|
52
|
+
calendarEl.addEventListener('change', handler);
|
|
53
|
+
return () => calendarEl?.removeEventListener('change', handler);
|
|
54
|
+
});
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
56
|
+
let debounceTimer: ReturnType<typeof setTimeout>;
|
|
57
|
+
function setText(value: string) {
|
|
58
|
+
filter.setText(column.attribute, value);
|
|
59
|
+
clearTimeout(debounceTimer);
|
|
60
|
+
debounceTimer = setTimeout(() => onchange?.(), 300);
|
|
61
|
+
}
|
|
62
|
+
function toggle(value: string) {
|
|
63
|
+
filter.toggleValue(column.attribute, value);
|
|
64
|
+
onchange?.();
|
|
65
|
+
}
|
|
66
|
+
function clear() {
|
|
67
|
+
filter.clear(column.attribute);
|
|
68
|
+
onchange?.();
|
|
69
|
+
}
|
|
70
70
|
</script>
|
|
71
71
|
|
|
72
72
|
<Button
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
style="anchor-name:{anchor}"
|
|
80
|
-
aria-label={strings.filterColumn(column.title ?? column.attribute)}
|
|
81
|
-
title={strings.filter}
|
|
73
|
+
variant="ghost"
|
|
74
|
+
class={['btn-xs btn-square', filter.hasActive(column.attribute) && 'text-primary']}
|
|
75
|
+
popovertarget={popId}
|
|
76
|
+
style="anchor-name:{anchor}"
|
|
77
|
+
aria-label={strings.filterColumn(column.title ?? column.attribute)}
|
|
78
|
+
title={strings.filter}
|
|
82
79
|
>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
80
|
+
{#if filter.hasActive(column.attribute)}
|
|
81
|
+
{@const Icon = icons.filterActive}
|
|
82
|
+
<Icon class="size-3.5" />
|
|
83
|
+
{:else}
|
|
84
|
+
{@const Icon = icons.filter}
|
|
85
|
+
<Icon class="size-3.5" />
|
|
86
|
+
{/if}
|
|
90
87
|
</Button>
|
|
91
88
|
|
|
92
89
|
<div
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
popover="auto"
|
|
91
|
+
id={popId}
|
|
92
|
+
style="position-anchor:{anchor}; position-try-fallbacks:flip-block;"
|
|
93
|
+
class="dropdown dropdown-end rounded-box border border-base-content/10 bg-base-100 p-3 shadow-lg"
|
|
94
|
+
class:w-56={!isDatetime}
|
|
98
95
|
>
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
96
|
+
{#if isDatetime}
|
|
97
|
+
<calendar-range class="cally bg-base-100" value={calendarValue} bind:this={calendarEl}>
|
|
98
|
+
<svg
|
|
99
|
+
aria-label={strings.previous}
|
|
100
|
+
class="fill-current size-4"
|
|
101
|
+
{...{ slot: 'previous' }}
|
|
102
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
103
|
+
viewBox="0 0 24 24"><path fill="currentColor" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg
|
|
104
|
+
>
|
|
105
|
+
<svg
|
|
106
|
+
aria-label={strings.next}
|
|
107
|
+
class="fill-current size-4"
|
|
108
|
+
{...{ slot: 'next' }}
|
|
109
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
110
|
+
viewBox="0 0 24 24"><path fill="currentColor" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg
|
|
111
|
+
>
|
|
112
|
+
<calendar-month></calendar-month>
|
|
113
|
+
</calendar-range>
|
|
114
|
+
{#if dateRange.from || dateRange.to}
|
|
115
|
+
<p class="mt-2 text-center text-xs text-base-content/60">
|
|
116
|
+
{dateRange.from || '…'} → {dateRange.to || '…'}
|
|
117
|
+
</p>
|
|
118
|
+
{/if}
|
|
119
|
+
{#if filter.hasActive(column.attribute)}
|
|
120
|
+
<Button variant="ghost" class="btn-sm mt-2 w-full" onclick={clear}>
|
|
121
|
+
{strings.clearFilter}
|
|
122
|
+
</Button>
|
|
123
|
+
{/if}
|
|
124
|
+
{:else}
|
|
125
|
+
<div class="relative">
|
|
126
|
+
<input
|
|
127
|
+
type="text"
|
|
128
|
+
class={[
|
|
129
|
+
'input input-bordered input-sm w-full',
|
|
130
|
+
filter.hasActive(column.attribute) && 'pr-7'
|
|
131
|
+
]}
|
|
132
|
+
placeholder={strings.filterPlaceholder}
|
|
133
|
+
value={filter.textFor(column.attribute)}
|
|
134
|
+
oninput={(e) => setText(e.currentTarget.value)}
|
|
135
|
+
/>
|
|
136
|
+
{#if filter.hasActive(column.attribute)}
|
|
137
|
+
{@const Icon = icons.clear}
|
|
138
|
+
<Button
|
|
139
|
+
variant="ghost"
|
|
140
|
+
class="btn-xs btn-square btn-circle absolute top-1/2 right-1 -translate-y-1/2"
|
|
141
|
+
aria-label={strings.clearFilter}
|
|
142
|
+
title={strings.clearFilter}
|
|
143
|
+
onclick={clear}
|
|
144
|
+
>
|
|
145
|
+
<Icon class="size-3" />
|
|
146
|
+
</Button>
|
|
147
|
+
{/if}
|
|
148
|
+
</div>
|
|
141
149
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
150
|
+
{#if entries.length > 0 && entries.length < maxCheckboxValues}
|
|
151
|
+
<div class="mt-2 flex max-h-60 flex-col gap-1 overflow-y-auto">
|
|
152
|
+
{#each entries as entry (entry.key)}
|
|
153
|
+
<Label class="flex cursor-pointer items-center gap-2 text-sm font-normal normal-case">
|
|
154
|
+
<input
|
|
155
|
+
type="checkbox"
|
|
156
|
+
class="checkbox checkbox-xs shrink-0"
|
|
157
|
+
checked={filter.isChecked(column.attribute, entry.key)}
|
|
158
|
+
onchange={() => toggle(entry.key)}
|
|
159
|
+
/>
|
|
160
|
+
<span class="truncate">
|
|
161
|
+
{entry.key === '' ? strings.emptyValue : (entry.label ?? entry.key)}
|
|
162
|
+
</span>
|
|
163
|
+
</Label>
|
|
164
|
+
{/each}
|
|
165
|
+
</div>
|
|
166
|
+
{/if}
|
|
167
|
+
{/if}
|
|
160
168
|
</div>
|
|
@@ -1,189 +1,215 @@
|
|
|
1
1
|
<script lang="ts" generics="T extends object = Record<string, unknown>">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
3
|
+
import TableBody from './TableBody.svelte';
|
|
4
|
+
import Paginator from './Paginator.svelte';
|
|
5
|
+
import TableHeader from './TableHeader.svelte';
|
|
6
|
+
import { SortState, FilterState, snapshotFilter } from './state.svelte.js';
|
|
7
|
+
import { distinctEntries, isFilterable } from './utils.js';
|
|
8
|
+
import type {
|
|
9
|
+
FilterSnapshot,
|
|
10
|
+
IndexedRow,
|
|
11
|
+
ServerPagination,
|
|
12
|
+
SortDirection,
|
|
13
|
+
TableQuery
|
|
14
|
+
} from '../../types/table.js';
|
|
15
|
+
import type { Snippet } from 'svelte';
|
|
16
|
+
import type { ColumnDefinition } from '../../types/crud.js';
|
|
17
|
+
import { getStrings } from '../../i18n/context.js';
|
|
18
|
+
|
|
19
|
+
const strings = getStrings();
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
data = [] as T[],
|
|
23
|
+
columns = [] as ColumnDefinition<T>[],
|
|
24
|
+
pageSize = 10,
|
|
25
|
+
selectable = true,
|
|
26
|
+
selected = $bindable(new SvelteSet<number>()),
|
|
27
|
+
rowActions = undefined as Snippet<[T]> | undefined,
|
|
28
|
+
actionsLabel = strings.actions,
|
|
29
|
+
pagination = undefined as ServerPagination | undefined,
|
|
30
|
+
initialSort = undefined as { column: string; direction: SortDirection } | undefined,
|
|
31
|
+
initialFilters = undefined as Partial<FilterSnapshot> | undefined,
|
|
32
|
+
onPaginationChange = undefined as ((query: TableQuery) => void) | undefined,
|
|
33
|
+
visibleRows = $bindable<T[]>([]),
|
|
34
|
+
query = $bindable<TableQuery | undefined>(undefined)
|
|
35
|
+
}: {
|
|
36
|
+
data?: T[];
|
|
37
|
+
columns?: ColumnDefinition<T>[];
|
|
38
|
+
pageSize?: number;
|
|
39
|
+
selectable?: boolean;
|
|
40
|
+
selected?: SvelteSet<number>;
|
|
41
|
+
rowActions?: Snippet<[T]>;
|
|
42
|
+
actionsLabel?: string;
|
|
43
|
+
/** When provided, the table trusts `data` is already the requested page and
|
|
44
|
+
* defers pagination/sort/filter to `onPaginationChange` instead of computing
|
|
45
|
+
* them locally. Omit for the original fully-client-side behavior. */
|
|
46
|
+
pagination?: ServerPagination;
|
|
47
|
+
initialSort?: { column: string; direction: SortDirection };
|
|
48
|
+
initialFilters?: Partial<FilterSnapshot>;
|
|
49
|
+
onPaginationChange?: (query: TableQuery) => void;
|
|
50
|
+
/** Filtered + sorted rows before page slicing (client mode), or the
|
|
51
|
+
* current page's rows as-is (server mode). Read-only for callers. */
|
|
52
|
+
visibleRows?: T[];
|
|
53
|
+
/** Current ordering + filters snapshot, kept in sync for callers that
|
|
54
|
+
* need to replicate the active query (e.g. exporting server-side). */
|
|
55
|
+
query?: TableQuery;
|
|
56
|
+
} = $props();
|
|
57
|
+
|
|
58
|
+
// Intentional one-time hydration of local state from the initial prop
|
|
59
|
+
// values (not a live binding) — `svelte-check`'s state_referenced_locally
|
|
60
|
+
// warning is a false positive here.
|
|
61
|
+
const sort = new SortState(initialSort ?? null);
|
|
62
|
+
const filter = new FilterState(initialFilters ?? null);
|
|
63
|
+
|
|
64
|
+
let currentPage = $state(pagination?.page ?? 1);
|
|
65
|
+
let lastKnownPage = pagination?.page ?? 1;
|
|
66
|
+
|
|
67
|
+
const serverFilterSampleSize = 5;
|
|
68
|
+
|
|
69
|
+
// Server mode: there's no way to know every value a column can take without
|
|
70
|
+
// querying the whole (server-owned) dataset, so this is deliberately just a
|
|
71
|
+
// cosmetic hint — up to `serverFilterSampleSize` distinct values found on
|
|
72
|
+
// the current page, not an exhaustive list. Boolean is the one exception:
|
|
73
|
+
// its two states are always known, so both show up regardless of what the
|
|
74
|
+
// current page happens to contain.
|
|
75
|
+
const distinctValues = $derived(
|
|
76
|
+
pagination
|
|
77
|
+
? {
|
|
78
|
+
...Object.fromEntries(
|
|
79
|
+
Object.entries(
|
|
80
|
+
distinctEntries(
|
|
81
|
+
data,
|
|
82
|
+
columns.filter((c) => isFilterable(c) && c.type !== 'boolean')
|
|
83
|
+
)
|
|
84
|
+
).map(([attribute, entries]) => [attribute, entries.slice(0, serverFilterSampleSize)])
|
|
85
|
+
),
|
|
86
|
+
...Object.fromEntries(
|
|
87
|
+
columns
|
|
88
|
+
.filter((c) => isFilterable(c) && c.type === 'boolean')
|
|
89
|
+
.map((c) => [
|
|
90
|
+
c.attribute,
|
|
91
|
+
[
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
93
|
+
{ key: 'true', label: c.formatter?.(true as any, {} as T), row: {} as T },
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
{ key: 'false', label: c.formatter?.(false as any, {} as T), row: {} as T }
|
|
96
|
+
]
|
|
97
|
+
])
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
: distinctEntries(data, columns)
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const indexed = $derived(data.map((row, index): IndexedRow<T> => ({ row, index })));
|
|
104
|
+
const filtered = $derived(
|
|
105
|
+
pagination ? indexed : indexed.filter(({ row }) => filter.matches(row, columns))
|
|
106
|
+
);
|
|
107
|
+
const sorted = $derived(pagination ? filtered : sort.apply(filtered, columns));
|
|
108
|
+
|
|
109
|
+
const effectivePageSize = $derived(pagination?.pageSize ?? pageSize);
|
|
110
|
+
const totalPages = $derived(
|
|
111
|
+
pagination?.totalPages ?? Math.ceil(sorted.length / effectivePageSize)
|
|
112
|
+
);
|
|
113
|
+
const displayPage = $derived(pagination?.page ?? currentPage);
|
|
114
|
+
const pageStart = $derived((displayPage - 1) * effectivePageSize);
|
|
115
|
+
const pageData = $derived(
|
|
116
|
+
pagination ? sorted : sorted.slice(pageStart, pageStart + effectivePageSize)
|
|
117
|
+
);
|
|
118
|
+
const totalCount = $derived(pagination?.total ?? sorted.length);
|
|
119
|
+
const allChecked = $derived(pageData.length > 0 && pageData.every((e) => selected.has(e.index)));
|
|
120
|
+
const someChecked = $derived(pageData.some((e) => selected.has(e.index)));
|
|
121
|
+
|
|
122
|
+
// Client mode only: server mode's totalPages is externally owned, clamping
|
|
123
|
+
// here would fight with URL-driven navigation while a page reload is pending.
|
|
124
|
+
$effect(() => {
|
|
125
|
+
if (!pagination && currentPage > totalPages && totalPages > 0) currentPage = totalPages;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Server mode: external (URL/reload) page changes -> sync local state.
|
|
129
|
+
$effect(() => {
|
|
130
|
+
if (pagination && pagination.page !== lastKnownPage) {
|
|
131
|
+
currentPage = pagination.page;
|
|
132
|
+
lastKnownPage = pagination.page;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Server mode: local (Paginator click) page changes -> notify caller.
|
|
137
|
+
$effect(() => {
|
|
138
|
+
if (pagination && currentPage !== lastKnownPage) {
|
|
139
|
+
lastKnownPage = currentPage;
|
|
140
|
+
onPaginationChange?.(currentQuery(currentPage));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Surface the filtered+sorted rows and current query for callers (e.g. export).
|
|
145
|
+
$effect(() => {
|
|
146
|
+
visibleRows = sorted.map((e) => e.row);
|
|
147
|
+
});
|
|
148
|
+
$effect(() => {
|
|
149
|
+
query = currentQuery(displayPage);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function currentQuery(page: number): TableQuery {
|
|
153
|
+
return {
|
|
154
|
+
page,
|
|
155
|
+
ordering: sort.column ? (sort.direction === 'asc' ? sort.column : `-${sort.column}`) : null,
|
|
156
|
+
filters: snapshotFilter(filter)
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function handleHeaderChange() {
|
|
161
|
+
currentPage = 1;
|
|
162
|
+
if (!pagination) return;
|
|
163
|
+
lastKnownPage = 1;
|
|
164
|
+
onPaginationChange?.(currentQuery(1));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function toggleAll() {
|
|
168
|
+
if (allChecked) pageData.forEach((e) => selected.delete(e.index));
|
|
169
|
+
else pageData.forEach((e) => selected.add(e.index));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function toggleItem(index: number) {
|
|
173
|
+
if (selected.has(index)) selected.delete(index);
|
|
174
|
+
else selected.add(index);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const colCount = $derived(columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0));
|
|
152
178
|
</script>
|
|
153
179
|
|
|
154
180
|
<div class="flex flex-col gap-6">
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
181
|
+
<div class="min-w-0 overflow-x-auto rounded-box border border-base-content/10">
|
|
182
|
+
<table class="table table-zebra table-xs w-full text-xs sm:table-md sm:text-base">
|
|
183
|
+
<TableHeader
|
|
184
|
+
{columns}
|
|
185
|
+
{selectable}
|
|
186
|
+
{allChecked}
|
|
187
|
+
{someChecked}
|
|
188
|
+
onToggleAll={toggleAll}
|
|
189
|
+
{sort}
|
|
190
|
+
{filter}
|
|
191
|
+
{distinctValues}
|
|
192
|
+
hasRowActions={!!rowActions}
|
|
193
|
+
{actionsLabel}
|
|
194
|
+
onchange={handleHeaderChange}
|
|
195
|
+
/>
|
|
196
|
+
<TableBody
|
|
197
|
+
{columns}
|
|
198
|
+
rows={pageData}
|
|
199
|
+
{selectable}
|
|
200
|
+
{selected}
|
|
201
|
+
onToggle={toggleItem}
|
|
202
|
+
{colCount}
|
|
203
|
+
{rowActions}
|
|
204
|
+
/>
|
|
205
|
+
</table>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<Paginator
|
|
209
|
+
bind:page={currentPage}
|
|
210
|
+
{totalPages}
|
|
211
|
+
{pageStart}
|
|
212
|
+
pageSize={effectivePageSize}
|
|
213
|
+
total={totalCount}
|
|
214
|
+
/>
|
|
189
215
|
</div>
|
|
@@ -1,95 +1,98 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import Button from '../form/Button.svelte';
|
|
3
|
+
import { getStrings } from '../../i18n/context.js';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const strings = getStrings();
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
7
|
+
let {
|
|
8
|
+
page = $bindable(1),
|
|
9
|
+
totalPages,
|
|
10
|
+
pageStart,
|
|
11
|
+
pageSize,
|
|
12
|
+
total
|
|
13
|
+
}: {
|
|
14
|
+
page?: number;
|
|
15
|
+
totalPages: number;
|
|
16
|
+
pageStart: number;
|
|
17
|
+
pageSize: number;
|
|
18
|
+
total: number;
|
|
19
|
+
} = $props();
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
function prev() {
|
|
22
|
+
if (page > 1) page--;
|
|
23
|
+
}
|
|
24
|
+
function next() {
|
|
25
|
+
if (page < totalPages) page++;
|
|
26
|
+
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
function buildPages(current: number, total: number): number[] {
|
|
29
|
+
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
// Local, immediately-discarded dedup set for this one calculation, not
|
|
32
|
+
// reactive state — a plain Set is correct here.
|
|
33
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
34
|
+
const visible = new Set<number>();
|
|
35
|
+
visible.add(1);
|
|
36
|
+
visible.add(total);
|
|
37
|
+
for (let i = Math.max(1, current - 2); i <= Math.min(total, current + 2); i++) {
|
|
38
|
+
visible.add(i);
|
|
39
|
+
}
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
const sorted = Array.from(visible).sort((a, b) => a - b);
|
|
42
|
+
const result: number[] = [];
|
|
43
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
44
|
+
if (i > 0 && sorted[i] - sorted[i - 1] > 1) result.push(0);
|
|
45
|
+
result.push(sorted[i]);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
const pages = $derived(buildPages(page, totalPages));
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
function handlePageInput(e: KeyboardEvent) {
|
|
53
|
+
if (e.key !== 'Enter') return;
|
|
54
|
+
const val = parseInt((e.currentTarget as HTMLInputElement).value);
|
|
55
|
+
if (!isNaN(val) && val >= 1 && val <= totalPages) {
|
|
56
|
+
page = val;
|
|
57
|
+
} else {
|
|
58
|
+
(e.currentTarget as HTMLInputElement).value = String(page);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
function resetInput(e: FocusEvent) {
|
|
63
|
+
(e.currentTarget as HTMLInputElement).value = String(page);
|
|
64
|
+
}
|
|
62
65
|
</script>
|
|
63
66
|
|
|
64
67
|
{#if totalPages > 1}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
<div class="flex items-center justify-between">
|
|
69
|
+
<span class="text-sm text-base-content/60">
|
|
70
|
+
{strings.showing(pageStart + 1, Math.min(pageStart + pageSize, total), total)}
|
|
71
|
+
</span>
|
|
69
72
|
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
<div class="join">
|
|
74
|
+
<Button class="join-item btn-sm" disabled={page === 1} onclick={prev}>«</Button>
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
76
|
+
{#each pages as p, i (i)}
|
|
77
|
+
{#if p === 0}
|
|
78
|
+
<Button class="join-item btn-sm" disabled>…</Button>
|
|
79
|
+
{:else if p === page}
|
|
80
|
+
<input
|
|
81
|
+
type="number"
|
|
82
|
+
class="join-item btn btn-sm w-12 text-center appearance-none"
|
|
83
|
+
style="background-color: var(--color-base-100);"
|
|
84
|
+
min="1"
|
|
85
|
+
max={totalPages}
|
|
86
|
+
value={page}
|
|
87
|
+
onkeydown={handlePageInput}
|
|
88
|
+
onblur={resetInput}
|
|
89
|
+
/>
|
|
90
|
+
{:else}
|
|
91
|
+
<Button class="join-item btn-sm" onclick={() => (page = p)}>{p}</Button>
|
|
92
|
+
{/if}
|
|
93
|
+
{/each}
|
|
91
94
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
<Button class="join-item btn-sm" disabled={page === totalPages} onclick={next}>»</Button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
95
98
|
{/if}
|
|
@@ -33,9 +33,13 @@ export class SortState {
|
|
|
33
33
|
const col = columns.find((c) => c.attribute === attribute);
|
|
34
34
|
const factor = direction === 'asc' ? 1 : -1;
|
|
35
35
|
if (col?.formatter) {
|
|
36
|
-
return [...rows].sort((a, b) => factor *
|
|
36
|
+
return [...rows].sort((a, b) => factor *
|
|
37
|
+
cellRenderedText(a.row, col).localeCompare(cellRenderedText(b.row, col), undefined, {
|
|
38
|
+
numeric: true
|
|
39
|
+
}));
|
|
37
40
|
}
|
|
38
|
-
return [...rows].sort((a, b) => factor *
|
|
41
|
+
return [...rows].sort((a, b) => factor *
|
|
42
|
+
compare(a.row[attribute], b.row[attribute]));
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
export class FilterState {
|
|
@@ -58,9 +62,9 @@ export class FilterState {
|
|
|
58
62
|
return this.dateRanges.get(attribute) ?? { from: '', to: '' };
|
|
59
63
|
}
|
|
60
64
|
hasActive(attribute) {
|
|
61
|
-
return (this.text.get(attribute)?.length ?? 0) > 0
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
return ((this.text.get(attribute)?.length ?? 0) > 0 ||
|
|
66
|
+
(this.values.get(attribute)?.size ?? 0) > 0 ||
|
|
67
|
+
!!(this.dateRanges.get(attribute)?.from || this.dateRanges.get(attribute)?.to));
|
|
64
68
|
}
|
|
65
69
|
isChecked(attribute, value) {
|
|
66
70
|
return this.values.get(attribute)?.has(value) ?? false;
|
|
@@ -101,11 +105,16 @@ export class FilterState {
|
|
|
101
105
|
const range = this.dateRanges.get(col.attribute);
|
|
102
106
|
if (range?.from || range?.to) {
|
|
103
107
|
const raw = row[col.attribute];
|
|
108
|
+
// Local, immediately-discarded timestamps for a one-off comparison,
|
|
109
|
+
// not reactive state — a plain Date is correct here.
|
|
110
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
104
111
|
const ts = raw ? new Date(raw).getTime() : NaN;
|
|
105
112
|
if (isNaN(ts))
|
|
106
113
|
return false;
|
|
114
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
107
115
|
if (range.from && ts < new Date(range.from).getTime())
|
|
108
116
|
return false;
|
|
117
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
109
118
|
if (range.to && ts > new Date(range.to + 'T23:59:59.999').getTime())
|
|
110
119
|
return false;
|
|
111
120
|
}
|
|
@@ -126,6 +135,6 @@ export function snapshotFilter(filter) {
|
|
|
126
135
|
return {
|
|
127
136
|
text: Object.fromEntries(filter.text),
|
|
128
137
|
values: Object.fromEntries([...filter.values].map(([k, set]) => [k, [...set]])),
|
|
129
|
-
dateRanges: Object.fromEntries(filter.dateRanges)
|
|
138
|
+
dateRanges: Object.fromEntries(filter.dateRanges)
|
|
130
139
|
};
|
|
131
140
|
}
|
package/dist/types/table.d.ts
CHANGED
|
@@ -4,8 +4,14 @@ export type IndexedRow<T> = {
|
|
|
4
4
|
row: T;
|
|
5
5
|
index: number;
|
|
6
6
|
};
|
|
7
|
+
/** `key` is the actual filter value (matched against row data, and sent
|
|
8
|
+
* server-side as-is) — `label` is only what's displayed for it, falling back
|
|
9
|
+
* to `key` when absent. They diverge for a formatted value whose match token
|
|
10
|
+
* isn't human text, e.g. a boolean column's `key: 'true'` paired with
|
|
11
|
+
* `label: 'Sí'` from the column's formatter. */
|
|
7
12
|
export type DistinctEntry<T> = {
|
|
8
13
|
key: string;
|
|
14
|
+
label?: string;
|
|
9
15
|
row: T;
|
|
10
16
|
};
|
|
11
17
|
export interface CellProps<T extends object = Record<string, unknown>, V = unknown> {
|