runeforge 0.0.32 → 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.
@@ -1,160 +1,168 @@
1
1
  <script lang="ts" generics="T extends object">
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';
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
- const strings = getStrings();
9
+ const strings = getStrings();
10
10
 
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';
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
- 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();
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
- 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');
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
- const dateRange = $derived(filter.dateRangeFor(column.attribute));
38
- const calendarValue = $derived(
39
- dateRange.from || dateRange.to ? `${dateRange.from}/${dateRange.to}` : ''
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
- let calendarEl: HTMLElement | undefined = $state();
42
+ let calendarEl: HTMLElement | undefined = $state();
43
43
 
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
- });
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
- 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
- }
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
- variant="ghost"
74
- class={[
75
- 'btn-xs btn-square',
76
- filter.hasActive(column.attribute) && 'text-primary'
77
- ]}
78
- popovertarget={popId}
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
- {#if filter.hasActive(column.attribute)}
84
- {@const Icon = icons.filterActive}
85
- <Icon class="size-3.5" />
86
- {:else}
87
- {@const Icon = icons.filter}
88
- <Icon class="size-3.5" />
89
- {/if}
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
- popover="auto"
94
- id={popId}
95
- style="position-anchor:{anchor}; position-try-fallbacks:flip-block;"
96
- class="dropdown dropdown-end rounded-box border border-base-content/10 bg-base-100 p-3 shadow-lg"
97
- class:w-56={!isDatetime}
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
- {#if isDatetime}
100
- <calendar-range
101
- class="cally bg-base-100"
102
- value={calendarValue}
103
- bind:this={calendarEl}
104
- >
105
- <svg aria-label={strings.previous} class="fill-current size-4" {...{"slot": "previous"}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
106
- <svg aria-label={strings.next} class="fill-current size-4" {...{"slot": "next"}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg>
107
- <calendar-month></calendar-month>
108
- </calendar-range>
109
- {#if dateRange.from || dateRange.to}
110
- <p class="mt-2 text-center text-xs text-base-content/60">
111
- {dateRange.from || ''} → {dateRange.to || '…'}
112
- </p>
113
- {/if}
114
- {#if filter.hasActive(column.attribute)}
115
- <Button variant="ghost" class="btn-sm mt-2 w-full" onclick={clear}>
116
- {strings.clearFilter}
117
- </Button>
118
- {/if}
119
- {:else}
120
- <div class="relative">
121
- <input
122
- type="text"
123
- class={['input input-bordered input-sm w-full', filter.hasActive(column.attribute) && 'pr-7']}
124
- placeholder={strings.filterPlaceholder}
125
- value={filter.textFor(column.attribute)}
126
- oninput={(e) => setText(e.currentTarget.value)}
127
- />
128
- {#if filter.hasActive(column.attribute)}
129
- {@const Icon = icons.clear}
130
- <Button
131
- variant="ghost"
132
- class="btn-xs btn-square btn-circle absolute top-1/2 right-1 -translate-y-1/2"
133
- aria-label={strings.clearFilter}
134
- title={strings.clearFilter}
135
- onclick={clear}
136
- >
137
- <Icon class="size-3" />
138
- </Button>
139
- {/if}
140
- </div>
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
- {#if entries.length > 0 && entries.length < maxCheckboxValues}
143
- <div class="mt-2 flex max-h-60 flex-col gap-1 overflow-y-auto">
144
- {#each entries as entry (entry.key)}
145
- <Label class="flex cursor-pointer items-center gap-2 text-sm font-normal normal-case">
146
- <input
147
- type="checkbox"
148
- class="checkbox checkbox-xs shrink-0"
149
- checked={filter.isChecked(column.attribute, entry.key)}
150
- onchange={() => toggle(entry.key)}
151
- />
152
- <span class="truncate">
153
- {entry.key === '' ? strings.emptyValue : entry.key}
154
- </span>
155
- </Label>
156
- {/each}
157
- </div>
158
- {/if}
159
- {/if}
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
- 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
- { key: 'true', row: {} as T },
76
- { key: 'false', row: {} as T },
77
- ],
78
- ]),
79
- )
80
- : distinctEntries(data, columns)
81
- );
82
-
83
- const indexed = $derived(data.map((row, index): IndexedRow<T> => ({ row, index })));
84
- const filtered = $derived(pagination ? indexed : indexed.filter(({ row }) => filter.matches(row, columns)));
85
- const sorted = $derived(pagination ? filtered : sort.apply(filtered, columns));
86
-
87
- const effectivePageSize = $derived(pagination?.pageSize ?? pageSize);
88
- const totalPages = $derived(pagination?.totalPages ?? Math.ceil(sorted.length / effectivePageSize));
89
- const displayPage = $derived(pagination?.page ?? currentPage);
90
- const pageStart = $derived((displayPage - 1) * effectivePageSize);
91
- const pageData = $derived(pagination ? sorted : sorted.slice(pageStart, pageStart + effectivePageSize));
92
- const totalCount = $derived(pagination?.total ?? sorted.length);
93
- const allChecked = $derived(pageData.length > 0 && pageData.every((e) => selected.has(e.index)));
94
- const someChecked = $derived(pageData.some((e) => selected.has(e.index)));
95
-
96
- // Client mode only: server mode's totalPages is externally owned, clamping
97
- // here would fight with URL-driven navigation while a page reload is pending.
98
- $effect(() => {
99
- if (!pagination && currentPage > totalPages && totalPages > 0) currentPage = totalPages;
100
- });
101
-
102
- // Server mode: external (URL/reload) page changes -> sync local state.
103
- $effect(() => {
104
- if (pagination && pagination.page !== lastKnownPage) {
105
- currentPage = pagination.page;
106
- lastKnownPage = pagination.page;
107
- }
108
- });
109
-
110
- // Server mode: local (Paginator click) page changes -> notify caller.
111
- $effect(() => {
112
- if (pagination && currentPage !== lastKnownPage) {
113
- lastKnownPage = currentPage;
114
- onPaginationChange?.(currentQuery(currentPage));
115
- }
116
- });
117
-
118
- // Surface the filtered+sorted rows and current query for callers (e.g. export).
119
- $effect(() => {
120
- visibleRows = sorted.map((e) => e.row);
121
- });
122
- $effect(() => {
123
- query = currentQuery(displayPage);
124
- });
125
-
126
- function currentQuery(page: number): TableQuery {
127
- return {
128
- page,
129
- ordering: sort.column ? (sort.direction === 'asc' ? sort.column : `-${sort.column}`) : null,
130
- filters: snapshotFilter(filter),
131
- };
132
- }
133
-
134
- function handleHeaderChange() {
135
- currentPage = 1;
136
- if (!pagination) return;
137
- lastKnownPage = 1;
138
- onPaginationChange?.(currentQuery(1));
139
- }
140
-
141
- function toggleAll() {
142
- if (allChecked) pageData.forEach((e) => selected.delete(e.index));
143
- else pageData.forEach((e) => selected.add(e.index));
144
- }
145
-
146
- function toggleItem(index: number) {
147
- if (selected.has(index)) selected.delete(index);
148
- else selected.add(index);
149
- }
150
-
151
- const colCount = $derived(columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0));
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
- <div class="min-w-0 overflow-x-auto rounded-box border border-base-content/10">
156
- <table class="table table-zebra table-xs w-full text-xs sm:table-md sm:text-base">
157
- <TableHeader
158
- {columns}
159
- {selectable}
160
- {allChecked}
161
- {someChecked}
162
- onToggleAll={toggleAll}
163
- {sort}
164
- {filter}
165
- {distinctValues}
166
- hasRowActions={!!rowActions}
167
- {actionsLabel}
168
- onchange={handleHeaderChange}
169
- />
170
- <TableBody
171
- {columns}
172
- rows={pageData}
173
- {selectable}
174
- {selected}
175
- onToggle={toggleItem}
176
- {colCount}
177
- {rowActions}
178
- />
179
- </table>
180
- </div>
181
-
182
- <Paginator
183
- bind:page={currentPage}
184
- {totalPages}
185
- {pageStart}
186
- pageSize={effectivePageSize}
187
- total={totalCount}
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>
@@ -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> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.32",
3
+ "version": "0.0.33",
4
4
  "description": "SvelteKit toolkit for building metadata-driven CRUD interfaces with tables, forms, and actions",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",