runeforge 0.0.31 → 0.0.33
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.
|
@@ -208,6 +208,7 @@
|
|
|
208
208
|
label: action.label,
|
|
209
209
|
icon: action.icon,
|
|
210
210
|
toggle: action.toggle,
|
|
211
|
+
class: action.class,
|
|
211
212
|
condition: action.condition,
|
|
212
213
|
run: (item: T) => onAction?.(action, item)
|
|
213
214
|
})),
|
|
@@ -234,10 +235,12 @@
|
|
|
234
235
|
<div class="flex justify-end items-center gap-1">
|
|
235
236
|
{#each rowActions as action (action.label)}
|
|
236
237
|
{#if action.condition?.(item) ?? true}
|
|
238
|
+
{@const resolvedClass =
|
|
239
|
+
typeof action.class === 'function' ? action.class(item) : action.class}
|
|
237
240
|
{#if action.toggle}
|
|
238
241
|
<input
|
|
239
242
|
type="checkbox"
|
|
240
|
-
class={['toggle toggle-sm',
|
|
243
|
+
class={['toggle toggle-sm', resolvedClass]}
|
|
241
244
|
title={action.label}
|
|
242
245
|
aria-label={action.label}
|
|
243
246
|
checked={action.toggle(item)}
|
|
@@ -251,7 +254,7 @@
|
|
|
251
254
|
{@const Icon = action.icon}
|
|
252
255
|
<Button
|
|
253
256
|
variant="ghost"
|
|
254
|
-
class={['btn-xs',
|
|
257
|
+
class={['btn-xs', resolvedClass]}
|
|
255
258
|
title={action.label}
|
|
256
259
|
aria-label={action.label}
|
|
257
260
|
onclick={(e) => {
|
|
@@ -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,197 @@
|
|
|
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 distinctValues = $derived(
|
|
68
|
+
pagination
|
|
69
|
+
? Object.fromEntries(
|
|
70
|
+
columns
|
|
71
|
+
.filter((c) => isFilterable(c) && c.type === 'boolean')
|
|
72
|
+
.map((c) => [
|
|
73
|
+
c.attribute,
|
|
74
|
+
[
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
+
{ key: 'true', label: c.formatter?.(true as any, {} as T), row: {} as T },
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
{ key: 'false', label: c.formatter?.(false as any, {} as T), row: {} as T }
|
|
79
|
+
]
|
|
80
|
+
])
|
|
81
|
+
)
|
|
82
|
+
: distinctEntries(data, columns)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const indexed = $derived(data.map((row, index): IndexedRow<T> => ({ row, index })));
|
|
86
|
+
const filtered = $derived(
|
|
87
|
+
pagination ? indexed : indexed.filter(({ row }) => filter.matches(row, columns))
|
|
88
|
+
);
|
|
89
|
+
const sorted = $derived(pagination ? filtered : sort.apply(filtered, columns));
|
|
90
|
+
|
|
91
|
+
const effectivePageSize = $derived(pagination?.pageSize ?? pageSize);
|
|
92
|
+
const totalPages = $derived(
|
|
93
|
+
pagination?.totalPages ?? Math.ceil(sorted.length / effectivePageSize)
|
|
94
|
+
);
|
|
95
|
+
const displayPage = $derived(pagination?.page ?? currentPage);
|
|
96
|
+
const pageStart = $derived((displayPage - 1) * effectivePageSize);
|
|
97
|
+
const pageData = $derived(
|
|
98
|
+
pagination ? sorted : sorted.slice(pageStart, pageStart + effectivePageSize)
|
|
99
|
+
);
|
|
100
|
+
const totalCount = $derived(pagination?.total ?? sorted.length);
|
|
101
|
+
const allChecked = $derived(pageData.length > 0 && pageData.every((e) => selected.has(e.index)));
|
|
102
|
+
const someChecked = $derived(pageData.some((e) => selected.has(e.index)));
|
|
103
|
+
|
|
104
|
+
// Client mode only: server mode's totalPages is externally owned, clamping
|
|
105
|
+
// here would fight with URL-driven navigation while a page reload is pending.
|
|
106
|
+
$effect(() => {
|
|
107
|
+
if (!pagination && currentPage > totalPages && totalPages > 0) currentPage = totalPages;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Server mode: external (URL/reload) page changes -> sync local state.
|
|
111
|
+
$effect(() => {
|
|
112
|
+
if (pagination && pagination.page !== lastKnownPage) {
|
|
113
|
+
currentPage = pagination.page;
|
|
114
|
+
lastKnownPage = pagination.page;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Server mode: local (Paginator click) page changes -> notify caller.
|
|
119
|
+
$effect(() => {
|
|
120
|
+
if (pagination && currentPage !== lastKnownPage) {
|
|
121
|
+
lastKnownPage = currentPage;
|
|
122
|
+
onPaginationChange?.(currentQuery(currentPage));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Surface the filtered+sorted rows and current query for callers (e.g. export).
|
|
127
|
+
$effect(() => {
|
|
128
|
+
visibleRows = sorted.map((e) => e.row);
|
|
129
|
+
});
|
|
130
|
+
$effect(() => {
|
|
131
|
+
query = currentQuery(displayPage);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function currentQuery(page: number): TableQuery {
|
|
135
|
+
return {
|
|
136
|
+
page,
|
|
137
|
+
ordering: sort.column ? (sort.direction === 'asc' ? sort.column : `-${sort.column}`) : null,
|
|
138
|
+
filters: snapshotFilter(filter)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleHeaderChange() {
|
|
143
|
+
currentPage = 1;
|
|
144
|
+
if (!pagination) return;
|
|
145
|
+
lastKnownPage = 1;
|
|
146
|
+
onPaginationChange?.(currentQuery(1));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function toggleAll() {
|
|
150
|
+
if (allChecked) pageData.forEach((e) => selected.delete(e.index));
|
|
151
|
+
else pageData.forEach((e) => selected.add(e.index));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toggleItem(index: number) {
|
|
155
|
+
if (selected.has(index)) selected.delete(index);
|
|
156
|
+
else selected.add(index);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const colCount = $derived(columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0));
|
|
152
160
|
</script>
|
|
153
161
|
|
|
154
162
|
<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
|
-
|
|
163
|
+
<div class="min-w-0 overflow-x-auto rounded-box border border-base-content/10">
|
|
164
|
+
<table class="table table-zebra table-xs w-full text-xs sm:table-md sm:text-base">
|
|
165
|
+
<TableHeader
|
|
166
|
+
{columns}
|
|
167
|
+
{selectable}
|
|
168
|
+
{allChecked}
|
|
169
|
+
{someChecked}
|
|
170
|
+
onToggleAll={toggleAll}
|
|
171
|
+
{sort}
|
|
172
|
+
{filter}
|
|
173
|
+
{distinctValues}
|
|
174
|
+
hasRowActions={!!rowActions}
|
|
175
|
+
{actionsLabel}
|
|
176
|
+
onchange={handleHeaderChange}
|
|
177
|
+
/>
|
|
178
|
+
<TableBody
|
|
179
|
+
{columns}
|
|
180
|
+
rows={pageData}
|
|
181
|
+
{selectable}
|
|
182
|
+
{selected}
|
|
183
|
+
onToggle={toggleItem}
|
|
184
|
+
{colCount}
|
|
185
|
+
{rowActions}
|
|
186
|
+
/>
|
|
187
|
+
</table>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<Paginator
|
|
191
|
+
bind:page={currentPage}
|
|
192
|
+
{totalPages}
|
|
193
|
+
{pageStart}
|
|
194
|
+
pageSize={effectivePageSize}
|
|
195
|
+
total={totalCount}
|
|
196
|
+
/>
|
|
189
197
|
</div>
|
package/dist/types/crud.d.ts
CHANGED
|
@@ -75,6 +75,11 @@ export interface CustomAction<T extends object = Record<string, unknown>> {
|
|
|
75
75
|
* flips once the underlying item's data actually changes — e.g. after
|
|
76
76
|
* the action's modal confirms and the list reloads. */
|
|
77
77
|
toggle?: (item: T) => boolean;
|
|
78
|
+
/** Extra class(es) on the row action's button/switch — e.g. to color a
|
|
79
|
+
* `toggle` by the item's own state (`toggle-success`/`toggle-error`).
|
|
80
|
+
* Pass a function to resolve it per item; a plain string applies to
|
|
81
|
+
* every row alike. */
|
|
82
|
+
class?: string | ((item: T) => string);
|
|
78
83
|
endpoint?: string;
|
|
79
84
|
condition?: (item: T) => boolean;
|
|
80
85
|
/** Renders as a modal-like panel when the action runs. Mutually exclusive
|
|
@@ -89,7 +94,8 @@ export interface RowAction<T extends object = Record<string, unknown>> {
|
|
|
89
94
|
icon?: any;
|
|
90
95
|
/** See `CustomAction.toggle` — same controlled-switch rendering. */
|
|
91
96
|
toggle?: (item: T) => boolean;
|
|
92
|
-
class
|
|
97
|
+
/** See `CustomAction.class`. */
|
|
98
|
+
class?: string | ((item: T) => string);
|
|
93
99
|
condition?: (item: T) => boolean;
|
|
94
100
|
run: (item: T) => void;
|
|
95
101
|
}
|
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> {
|