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
- 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,215 @@
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 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
- <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
- />
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
- import Button from '../form/Button.svelte';
3
- import { getStrings } from '../../i18n/context.js';
2
+ import Button from '../form/Button.svelte';
3
+ import { getStrings } from '../../i18n/context.js';
4
4
 
5
- const strings = getStrings();
5
+ const strings = getStrings();
6
6
 
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();
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
- function prev() {
22
- if (page > 1) page--;
23
- }
24
- function next() {
25
- if (page < totalPages) page++;
26
- }
21
+ function prev() {
22
+ if (page > 1) page--;
23
+ }
24
+ function next() {
25
+ if (page < totalPages) page++;
26
+ }
27
27
 
28
- function buildPages(current: number, total: number): number[] {
29
- if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
28
+ function buildPages(current: number, total: number): number[] {
29
+ if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
30
30
 
31
- const visible = new Set<number>();
32
- visible.add(1);
33
- visible.add(total);
34
- for (let i = Math.max(1, current - 2); i <= Math.min(total, current + 2); i++) {
35
- visible.add(i);
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
- const sorted = Array.from(visible).sort((a, b) => a - b);
39
- const result: number[] = [];
40
- for (let i = 0; i < sorted.length; i++) {
41
- if (i > 0 && sorted[i] - sorted[i - 1] > 1) result.push(0);
42
- result.push(sorted[i]);
43
- }
44
- return result;
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
- const pages = $derived(buildPages(page, totalPages));
50
+ const pages = $derived(buildPages(page, totalPages));
48
51
 
49
- function handlePageInput(e: KeyboardEvent) {
50
- if (e.key !== 'Enter') return;
51
- const val = parseInt((e.currentTarget as HTMLInputElement).value);
52
- if (!isNaN(val) && val >= 1 && val <= totalPages) {
53
- page = val;
54
- } else {
55
- (e.currentTarget as HTMLInputElement).value = String(page);
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
- function resetInput(e: FocusEvent) {
60
- (e.currentTarget as HTMLInputElement).value = String(page);
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
- <div class="flex items-center justify-between">
66
- <span class="text-sm text-base-content/60">
67
- {strings.showing(pageStart + 1, Math.min(pageStart + pageSize, total), total)}
68
- </span>
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
- <div class="join">
71
- <Button class="join-item btn-sm" disabled={page === 1} onclick={prev}>«</Button>
73
+ <div class="join">
74
+ <Button class="join-item btn-sm" disabled={page === 1} onclick={prev}>«</Button>
72
75
 
73
- {#each pages as p, i (i)}
74
- {#if p === 0}
75
- <Button class="join-item btn-sm" disabled>…</Button>
76
- {:else if p === page}
77
- <input
78
- type="number"
79
- class="join-item btn btn-sm w-12 text-center appearance-none"
80
- style="background-color: var(--color-base-100);"
81
- min="1"
82
- max={totalPages}
83
- value={page}
84
- onkeydown={handlePageInput}
85
- onblur={resetInput}
86
- />
87
- {:else}
88
- <Button class="join-item btn-sm" onclick={() => (page = p)}>{p}</Button>
89
- {/if}
90
- {/each}
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
- <Button class="join-item btn-sm" disabled={page === totalPages} onclick={next}>»</Button>
93
- </div>
94
- </div>
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 * cellRenderedText(a.row, col).localeCompare(cellRenderedText(b.row, col), undefined, { numeric: true }));
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 * compare(a.row[attribute], b.row[attribute]));
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
- || (this.values.get(attribute)?.size ?? 0) > 0
63
- || !!(this.dateRanges.get(attribute)?.from || this.dateRanges.get(attribute)?.to);
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
  }
@@ -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.35",
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",